commit 8920135306eb84f4dd3173e462cd9c47d2b863c8 Author: publish Date: Wed Aug 5 18:57:39 2026 +0200 build(spf): build26 from 45504a2b diff --git a/README.md b/README.md new file mode 100644 index 00000000..502e31b4 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# @videojs/spf + +[![package-badge]][package] + +> **⚠️ Beta** Experimental adoption in real projects. + +## Overview + +`@videojs/spf` is a lightweight, bundle-size-optimized streaming engine for Video.js 10. It provides +HLS playback with adaptive bitrate switching, WebVTT captions, and MSE support. + +## Concepts + +- [SPF fundamentals](./docs/fundamentals.md) +- [HLS engine composition](./docs/hls-engine.md) + +## Community + +If you need help with anything related to Video.js 10, or if you'd like to casually chat with other +members: + +- [Join Discord Server][discord] +- [See GitHub Discussions][gh-discussions] + +## License + +[Apache-2.0](./LICENSE) + +[package]: https://www.npmjs.com/package/@videojs/spf +[package-badge]: https://img.shields.io/npm/v/@videojs/spf?label=@videojs/spf +[discord]: https://discord.gg/JBqHh485uF +[gh-discussions]: https://github.com/videojs/v10/discussions diff --git a/dist/default/background-video.js b/dist/default/background-video.js new file mode 100644 index 00000000..7c166216 --- /dev/null +++ b/dist/default/background-video.js @@ -0,0 +1,3 @@ +import { createBackgroundVideoEngine } from "./playback/engines/background-video/engine.js"; +import { BackgroundVideoMediaElement, BackgroundVideoMediaMixin, backgroundVideoMediaDefaultProps } from "./playback/engines/background-video/adapter.js"; +export { BackgroundVideoMediaElement, BackgroundVideoMediaMixin, backgroundVideoMediaDefaultProps, createBackgroundVideoEngine }; diff --git a/dist/default/core/actors/create-machine-actor.js b/dist/default/core/actors/create-machine-actor.js new file mode 100644 index 00000000..886e2b1d --- /dev/null +++ b/dist/default/core/actors/create-machine-actor.js @@ -0,0 +1,91 @@ +import { untrack, update } from "../signals/primitives.js"; +import { createMachineCore } from "../machine.js"; +//#region src/core/actors/create-machine-actor.ts +/** +* Creates a message-driven actor from a declarative definition. +* +* The actor owns a reactive snapshot signal (state + context), an optional +* runner, and dispatches incoming messages to per-state handlers. `'destroyed'` +* is always the implicit terminal state — `destroy()` transitions there +* unconditionally and all subsequent `send()` calls are no-ops. +* +* When a state declares `onSettled`, the framework calls `runner.whenSettled()` +* after the handler returns. The runner owns the generation-token logic — if +* new tasks are scheduled before the current batch settles, the callback is +* automatically superseded. +* +* @example +* const actor = createMachineActor({ +* runner: () => new SerialRunner(), +* initial: 'idle', +* context: {}, +* states: { +* idle: { +* on: { +* load: (msg, { transition, runner }) => { +* segments.forEach(s => runner.schedule(new Task(...))); +* transition('loading'); +* } +* } +* }, +* loading: { +* onSettled: 'idle', +* on: { +* load: (msg, { runner }) => { +* runner.abortAll(); +* segments.forEach(s => runner.schedule(new Task(...))); +* } +* } +* } +* } +* }); +*/ +function createMachineActor(def) { + const runner = def.runner?.(); + const { snapshotSignal, getState, transition } = createMachineCore({ + value: def.initial, + context: def.context + }); + const getContext = () => untrack(() => snapshotSignal.get().context); + const setContext = (context) => { + update(snapshotSignal, { context }); + }; + return { + get snapshot() { + return snapshotSignal; + }, + send(message) { + const state = getState(); + if (state === "destroyed") return; + const handler = def.states[state]?.on?.[message.type]; + if (!handler) return; + handler(message, { + context: getContext(), + getContext, + transition: (to) => transition(to), + setContext, + ...runner ? { runner } : {} + }); + const newState = getState(); + if (newState !== "destroyed") { + const newStateDef = def.states[newState]; + if (newStateDef?.onSettled && runner) { + const targetState = newStateDef.onSettled; + runner.whenSettled(() => { + if (getState() !== newState) return; + transition(targetState); + }); + } + } + }, + destroy() { + if (getState() === "destroyed") return; + runner?.destroy(); + transition("destroyed"); + } + }; +} +//#endregion +export { createMachineActor }; + +//# sourceMappingURL=create-machine-actor.js.map \ No newline at end of file diff --git a/dist/default/core/actors/create-machine-actor.js.map b/dist/default/core/actors/create-machine-actor.js.map new file mode 100644 index 00000000..788bbf95 --- /dev/null +++ b/dist/default/core/actors/create-machine-actor.js.map @@ -0,0 +1 @@ +{"version":3,"file":"create-machine-actor.js","names":[],"sources":["../../../../src/core/actors/create-machine-actor.ts"],"sourcesContent":["import { createMachineCore } from '../machine';\nimport { untrack, update } from '../signals/primitives';\nimport type { TaskLike } from '../tasks/task';\nimport type { ActorSnapshot, SignalActor } from './actor';\n\n// =============================================================================\n// Runner interfaces\n// =============================================================================\n\n/**\n * Minimal interface for any runner that can be used with createMachineActor.\n */\nexport interface RunnerLike {\n schedule(task: TaskLike): Promise;\n abortAll(): void;\n destroy(): void;\n whenSettled(callback: () => void): void;\n}\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * Context passed to message handlers.\n * `runner` is present and typed as the exact runner instance only when the\n * definition includes a runner factory.\n */\nexport type HandlerContext<\n UserState extends string,\n Context extends object,\n RunnerFactory extends (() => RunnerLike) | undefined,\n> = {\n transition: (to: UserState) => void;\n /** Context snapshot captured at dispatch time. Stale after any `setContext` call. */\n context: Context;\n /**\n * Live untracked read of the current context. Use in async task closures that\n * execute after the handler returns — e.g. `getCtx: getContext` passed to tasks\n * scheduled on the runner, so each task reads the context committed by the\n * previous task rather than the stale snapshot from dispatch time.\n */\n getContext: () => Context;\n setContext: (next: Context) => void;\n} & (RunnerFactory extends () => infer R ? { runner: R } : object);\n\n/**\n * Definition for a single user-defined state.\n */\nexport type ActorStateDefinition<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined,\n> = {\n /**\n * When the actor's runner settles while in this state, automatically\n * transition to this state. The framework owns the generation-token logic —\n * re-registering after each `runner.schedule()` call so that\n * `abortAll()` + reschedule correctly supersedes stale callbacks.\n */\n onSettled?: UserState;\n /** Message handlers active in this state. Messages with no handler are silently dropped. */\n on?: {\n [M in Message as M['type']]?: (\n message: Extract,\n ctx: HandlerContext\n ) => void;\n };\n};\n\n/**\n * Full actor definition passed to `createMachineActor`.\n *\n * `UserState` is the set of domain-meaningful states. `'destroyed'` is always\n * added by the framework as the implicit terminal state — do not include it here.\n */\nexport type ActorDefinition<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined = undefined,\n> = {\n /**\n * Runner factory — called once at `createMachineActor()` time.\n * The runner lives for the full actor lifetime and is destroyed with it.\n *\n * @example\n * runner: () => new SerialRunner()\n */\n runner?: RunnerFactory;\n /** Initial state. */\n initial: UserState;\n /** Initial context. */\n context: Context;\n /**\n * Per-state definitions. States with no definition silently drop all messages.\n * All user-defined states must appear as keys in the `UserState` union.\n */\n states: Partial>>;\n};\n\n// =============================================================================\n// Live actor interface\n// =============================================================================\n\n/** Live actor instance returned by `createMachineActor`. */\nexport interface MessageActor\n extends SignalActor {\n send(message: Message): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a message-driven actor from a declarative definition.\n *\n * The actor owns a reactive snapshot signal (state + context), an optional\n * runner, and dispatches incoming messages to per-state handlers. `'destroyed'`\n * is always the implicit terminal state — `destroy()` transitions there\n * unconditionally and all subsequent `send()` calls are no-ops.\n *\n * When a state declares `onSettled`, the framework calls `runner.whenSettled()`\n * after the handler returns. The runner owns the generation-token logic — if\n * new tasks are scheduled before the current batch settles, the callback is\n * automatically superseded.\n *\n * @example\n * const actor = createMachineActor({\n * runner: () => new SerialRunner(),\n * initial: 'idle',\n * context: {},\n * states: {\n * idle: {\n * on: {\n * load: (msg, { transition, runner }) => {\n * segments.forEach(s => runner.schedule(new Task(...)));\n * transition('loading');\n * }\n * }\n * },\n * loading: {\n * onSettled: 'idle',\n * on: {\n * load: (msg, { runner }) => {\n * runner.abortAll();\n * segments.forEach(s => runner.schedule(new Task(...)));\n * }\n * }\n * }\n * }\n * });\n */\nexport function createMachineActor<\n UserState extends string,\n Context extends object,\n Message extends { type: string },\n RunnerFactory extends (() => RunnerLike) | undefined = undefined,\n>(\n def: ActorDefinition\n): MessageActor {\n type FullState = UserState | 'destroyed';\n\n const runner = def.runner?.() as RunnerLike | undefined;\n const { snapshotSignal, getState, transition } = createMachineCore>({\n value: def.initial as FullState,\n context: def.context,\n });\n\n const getContext = (): Context => untrack(() => snapshotSignal.get().context);\n\n const setContext = (context: Context): void => {\n update(snapshotSignal, { context });\n };\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n send(message: Message): void {\n const state = getState();\n if (state === 'destroyed') return;\n const stateDef = def.states[state as UserState];\n const handler = stateDef?.on?.[message.type as keyof typeof stateDef.on] as\n | ((msg: Message, ctx: HandlerContext) => void)\n | undefined;\n if (!handler) return;\n handler(message, {\n context: getContext(),\n getContext,\n transition: (to: UserState) => transition(to as FullState),\n setContext,\n ...(runner ? { runner } : {}),\n } as HandlerContext);\n // Register onSettled after the handler so we read the post-transition state.\n const newState = getState();\n if (newState !== 'destroyed') {\n const newStateDef = def.states[newState as UserState];\n if (newStateDef?.onSettled && runner) {\n const targetState = newStateDef.onSettled as FullState;\n runner.whenSettled(() => {\n if (getState() !== newState) return;\n transition(targetState);\n });\n }\n }\n },\n\n destroy(): void {\n if (getState() === 'destroyed') return;\n runner?.destroy();\n transition('destroyed');\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2JA,SAAgB,mBAMd,KACyD;CAGzD,MAAM,SAAS,IAAI,SAAS;CAC5B,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAAgE;EAC/G,OAAO,IAAI;EACX,SAAS,IAAI;CACf,CAAC;CAED,MAAM,mBAA4B,cAAc,eAAe,IAAI,CAAC,CAAC,OAAO;CAE5E,MAAM,cAAc,YAA2B;EAC7C,OAAO,gBAAgB,EAAE,QAAQ,CAAC;CACpC;CAEA,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA,KAAK,SAAwB;GAC3B,MAAM,QAAQ,SAAS;GACvB,IAAI,UAAU,aAAa;GAE3B,MAAM,UADW,IAAI,OAAO,MACJ,EAAE,KAAK,QAAQ;GAGvC,IAAI,CAAC,SAAS;GACd,QAAQ,SAAS;IACf,SAAS,WAAW;IACpB;IACA,aAAa,OAAkB,WAAW,EAAe;IACzD;IACA,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;GAC7B,CAAsD;GAEtD,MAAM,WAAW,SAAS;GAC1B,IAAI,aAAa,aAAa;IAC5B,MAAM,cAAc,IAAI,OAAO;IAC/B,IAAI,aAAa,aAAa,QAAQ;KACpC,MAAM,cAAc,YAAY;KAChC,OAAO,kBAAkB;MACvB,IAAI,SAAS,MAAM,UAAU;MAC7B,WAAW,WAAW;KACxB,CAAC;IACH;GACF;EACF;EAEA,UAAgB;GACd,IAAI,SAAS,MAAM,aAAa;GAChC,QAAQ,QAAQ;GAChB,WAAW,WAAW;EACxB;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/core/actors/create-transition-actor.js b/dist/default/core/actors/create-transition-actor.js new file mode 100644 index 00000000..e4a4b098 --- /dev/null +++ b/dist/default/core/actors/create-transition-actor.js @@ -0,0 +1,46 @@ +import { untrack, update } from "../signals/primitives.js"; +import { createMachineCore } from "../machine.js"; +//#region src/core/actors/create-transition-actor.ts +/** +* Creates a reducer-shaped actor from an initial context and a reducer function. +* +* The reducer receives the current context and a message and returns the next +* context. Returning the same reference (by identity) skips the signal update — +* so early-returning `context` unchanged is both the no-op and the optimization. +* +* Side effects (e.g. DOM mutations) may be performed inside the reducer. +* They run synchronously before the signal is updated. +* +* @example +* const actor = createTransitionActor( +* { count: 0 }, +* (context, message: { type: 'increment' }) => ({ count: context.count + 1 }) +* ); +*/ +function createTransitionActor(initialContext, reducer) { + const { snapshotSignal, getState, transition } = createMachineCore({ + value: "active", + context: initialContext + }); + const getContext = () => untrack(() => snapshotSignal.get().context); + const setContext = (context) => update(snapshotSignal, { context }); + return { + get snapshot() { + return snapshotSignal; + }, + send(message) { + if (getState() === "destroyed") return; + const context = getContext(); + const newContext = reducer(context, message); + if (newContext !== context) setContext(newContext); + }, + destroy() { + if (getState() === "destroyed") return; + transition("destroyed"); + } + }; +} +//#endregion +export { createTransitionActor }; + +//# sourceMappingURL=create-transition-actor.js.map \ No newline at end of file diff --git a/dist/default/core/actors/create-transition-actor.js.map b/dist/default/core/actors/create-transition-actor.js.map new file mode 100644 index 00000000..a70925ef --- /dev/null +++ b/dist/default/core/actors/create-transition-actor.js.map @@ -0,0 +1 @@ +{"version":3,"file":"create-transition-actor.js","names":[],"sources":["../../../../src/core/actors/create-transition-actor.ts"],"sourcesContent":["import type { Machine } from '../machine';\nimport { createMachineCore } from '../machine';\nimport { untrack, update } from '../signals/primitives';\nimport type { ActorSnapshot } from './actor';\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * A reducer-shaped actor: `(context, message) => context`.\n *\n * No finite states — the snapshot carries `value: 'active' | 'destroyed'`\n * as a universal lifecycle marker rather than domain state. The interesting\n * state is entirely in the context, which is reactive via `snapshot`.\n *\n * Use this when the actor has context that needs to be reactive but no\n * meaningful state machine (e.g., a message-driven model with DOM side\n * effects). For actors that need per-state behavior, use `createMachineActor`.\n */\nexport interface TransitionActor\n extends Machine> {\n send(message: Message): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a reducer-shaped actor from an initial context and a reducer function.\n *\n * The reducer receives the current context and a message and returns the next\n * context. Returning the same reference (by identity) skips the signal update —\n * so early-returning `context` unchanged is both the no-op and the optimization.\n *\n * Side effects (e.g. DOM mutations) may be performed inside the reducer.\n * They run synchronously before the signal is updated.\n *\n * @example\n * const actor = createTransitionActor(\n * { count: 0 },\n * (context, message: { type: 'increment' }) => ({ count: context.count + 1 })\n * );\n */\nexport function createTransitionActor(\n initialContext: Context,\n reducer: (context: Context, message: Message) => Context\n): TransitionActor {\n const { snapshotSignal, getState, transition } = createMachineCore<\n 'active' | 'destroyed',\n ActorSnapshot<'active' | 'destroyed', Context>\n >({ value: 'active', context: initialContext });\n\n const getContext = (): Context => untrack(() => snapshotSignal.get().context);\n const setContext = (context: Context): void => update(snapshotSignal, { context });\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n send(message: Message): void {\n if (getState() === 'destroyed') return;\n const context = getContext();\n const newContext = reducer(context, message);\n if (newContext !== context) setContext(newContext);\n },\n\n destroy(): void {\n if (getState() === 'destroyed') return;\n transition('destroyed');\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6CA,SAAgB,sBACd,gBACA,SACmC;CACnC,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAG/C;EAAE,OAAO;EAAU,SAAS;CAAe,CAAC;CAE9C,MAAM,mBAA4B,cAAc,eAAe,IAAI,CAAC,CAAC,OAAO;CAC5E,MAAM,cAAc,YAA2B,OAAO,gBAAgB,EAAE,QAAQ,CAAC;CAEjF,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA,KAAK,SAAwB;GAC3B,IAAI,SAAS,MAAM,aAAa;GAChC,MAAM,UAAU,WAAW;GAC3B,MAAM,aAAa,QAAQ,SAAS,OAAO;GAC3C,IAAI,eAAe,SAAS,WAAW,UAAU;EACnD;EAEA,UAAgB;GACd,IAAI,SAAS,MAAM,aAAa;GAChC,WAAW,WAAW;EACxB;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/core/composition/create-composition.js b/dist/default/core/composition/create-composition.js new file mode 100644 index 00000000..448fc5d6 --- /dev/null +++ b/dist/default/core/composition/create-composition.js @@ -0,0 +1,85 @@ +import { signal } from "../signals/primitives.js"; +//#region src/core/composition/create-composition.ts +/** +* Create a composition from a set of behaviors. +* +* Composition unions the behaviors' declared `stateKeys` / `contextKeys` +* to know which signals to create. Each signal is seeded from +* `initialState` / `initialContext` when supplied, defaulting to +* `undefined`. Behaviors are responsible for writing their own slots +* once their preconditions are met. +* +* Cross-behavior type conflicts (e.g. two behaviors disagreeing on a +* field's type) surface as a compose-time type error via +* `ValidateComposition`. +* +* @example +* ```ts +* const composition = createComposition([resolvePresentation, switchVideoTrack], { +* config: { parsePresentation: parseMultivariantPlaylist, initialBandwidth: 2_000_000 }, +* initialState: { bandwidthState: { fastEstimate: 0, ... } }, +* }); +* ``` +*/ +/** +* Create a typed signal map for a given set of keys, seeded from an +* optional partial initial value. +* +* Pipeline: `Set` dedupes the iterable (insertion order preserved, so +* first occurrence wins) → `Object.fromEntries` materializes one +* `signal()` per unique key, seeded from `initial[key]` or `undefined`. +* +* Per-key value types live in TypeScript only — at runtime every signal +* is `Signal`. The boundary cast at the return narrows the wide +* `Record>` shape to the caller's expected +* per-key types from `S`. +* +* Used by `createComposition` to derive engine state/context maps from +* the union of behaviors' declared `stateKeys` / `contextKeys`. +* +* @example +* ```ts +* interface State { count?: number; label?: string } +* const state = buildSignalMap(['count', 'label'], { count: 5 }); +* state.count.get(); // 5 +* state.label.get(); // undefined +* ``` +*/ +function buildSignalMap(keys, initial) { + const init = initial; + const uniqueKeys = new Set(keys); + return Object.fromEntries([...uniqueKeys].map((key) => [key, signal(init[key])])); +} +function createComposition(behaviors, options) { + const validBehaviors = behaviors; + const state = buildSignalMap(validBehaviors.flatMap((b) => b.stateKeys), options?.initialState ?? {}); + const context = buildSignalMap(validBehaviors.flatMap((b) => b.contextKeys), options?.initialContext ?? {}); + const deps = { + state, + context, + config: options?.config ?? {} + }; + const cleanups = validBehaviors.map((behavior) => behavior.setup(deps)); + return { + state, + context, + async destroy() { + const results = []; + for (const cleanup of cleanups) { + if (cleanup == null) continue; + if (typeof cleanup === "function") results.push(cleanup()); + else if ("destroy" in cleanup) results.push(cleanup.destroy()); + } + await Promise.all(results); + for (const sig of Object.values(state)) sig.set(void 0); + for (const sig of Object.values(context)) sig.set(void 0); + } + }; +} +function defineBehavior(behavior) { + return behavior; +} +//#endregion +export { buildSignalMap, createComposition, defineBehavior }; + +//# sourceMappingURL=create-composition.js.map \ No newline at end of file diff --git a/dist/default/core/composition/create-composition.js.map b/dist/default/core/composition/create-composition.js.map new file mode 100644 index 00000000..235457d5 --- /dev/null +++ b/dist/default/core/composition/create-composition.js.map @@ -0,0 +1 @@ +{"version":3,"file":"create-composition.js","names":[],"sources":["../../../../src/core/composition/create-composition.ts"],"sourcesContent":["import { type ReadonlySignal, type Signal, signal } from '../signals/primitives';\n\n/**\n * Cleanup returned by a behavior. Behaviors may return:\n * - `void` / `undefined` — no cleanup needed\n * - A function — called on destroy (may return a Promise)\n * - An object with `destroy()` — called on destroy (may return a Promise)\n */\nexport type BehaviorCleanup = void | (() => void | Promise) | { destroy(): void | Promise };\n\n/**\n * A signal map keyed by the fields of `S`. Each field is a writable signal.\n *\n * Optional fields on `S` map to required signal slots whose value type\n * includes `undefined`, ensuring every key has a signal even when the\n * underlying value is absent.\n *\n * Used in two roles:\n * - Engine-side **construction**: `Composition` exposes its public\n * surface as `StateSignals` (everything writable) so external code\n * can read or write any slot.\n * - Behavior **input convenience**: a behavior that writes to every slot\n * can type its setup state param as `StateSignals<{ ... }>` rather than\n * spelling out per-slot `Signal` types.\n *\n * Behaviors that mix read-only and writable slots type the setup param\n * directly as a slot map (`{ x: Signal; y: ReadonlySignal }`)\n * instead of going through `StateSignals<>`.\n */\nexport type StateSignals = { [K in keyof S]-?: Signal };\n\n/**\n * A signal map keyed by the fields of `C`. Each field is a writable signal\n * for a platform object or actor reference. Same dual role as\n * `StateSignals` — see its docblock.\n */\nexport type ContextSignals = { [K in keyof C]-?: Signal };\n\n/**\n * Slot-map shape — a record where each value is at least a `ReadonlySignal`.\n * `Signal` is structurally a subtype of `ReadonlySignal` (it adds\n * `.set()`), so a writable slot satisfies this bound too.\n *\n * This is the bound used for behavior `state` / `context` slot maps. It\n * lets a single behavior declare a *heterogeneous* slot map where some\n * slots are `Signal` (writable) and others are `ReadonlySignal`\n * (read-only) — making read/write intent explicit at the call site and\n * giving body-level enforcement (TS rejects `.set()` on a read-only slot).\n */\nexport type AnySlotMap = Record>;\n\n/**\n * The deps object passed to each behavior by the composition.\n *\n * - `state` — slot map for state fields (reactive data). Per-slot read/\n * write intent expressed via `Signal` vs `ReadonlySignal`.\n * - `context` — slot map for platform objects and actor references.\n * - `config` — static configuration, passed once at composition creation.\n */\nexport interface BehaviorDeps {\n state: StateMap;\n context: ContextMap;\n config: Cfg;\n}\n\n/**\n * A behavior announces the state and context keys it needs alongside a\n * `setup` function that receives deps (state, context, config) and\n * returns an optional cleanup handle.\n *\n * The `stateKeys` / `contextKeys` declarations are the runtime expression\n * of the behavior's contract — the caller (e.g. `createComposition`) uses\n * them to know which signals to provide. The setup parameter type\n * declares the *slot map* (per-slot `Signal` vs `ReadonlySignal`);\n * together they form a complete contract.\n *\n * Manual `Behavior<>` literals (e.g. engine wrappers that forward keys\n * from a wrapped behavior, or pass-through behaviors like `shareSignals`)\n * opt out of exhaustiveness — the type alias is permissive (subset).\n * Source behaviors should use `defineBehavior` to get exhaustiveness\n * enforcement at the call site.\n */\nexport interface Behavior<\n StateMap extends AnySlotMap = Empty,\n ContextMap extends AnySlotMap = Empty,\n Cfg extends object = Empty,\n> {\n /** State keys this behavior reads/writes. Subset of `keyof StateMap`. */\n stateKeys: readonly (keyof StateMap)[];\n /** Context keys this behavior reads/writes. Subset of `keyof ContextMap`. */\n contextKeys: readonly (keyof ContextMap)[];\n setup: (deps: BehaviorDeps) => BehaviorCleanup;\n}\n\n// =============================================================================\n// Behavior type inference\n// =============================================================================\n\n/** A behavior with an unconstrained setup — used as a generic bound. */\ntype AnyBehavior = {\n stateKeys: readonly PropertyKey[];\n contextKeys: readonly PropertyKey[];\n setup: (deps: any) => BehaviorCleanup;\n};\n\n/** Extract the deps type from a behavior's setup function. */\ntype DepsOf = B extends { setup: (deps: infer D, ...args: any[]) => any } ? D : never;\n\n/**\n * Empty-object fallback used when a behavior omits state, context, or config.\n *\n * Using `{}` rather than `object` is deliberate — `object & {x: T}` collapses\n * to `{x: never}` under TS's union-to-intersection conversion in some inference\n * contexts (likely a TS quirk around the `object` upper bound), whereas\n * `{} & {x: T}` simplifies cleanly to `{x: T}`.\n */\n// biome-ignore lint/complexity/noBannedTypes: see comment above\ntype Empty = {};\n\n/**\n * Unwrap a signal map back to its state/context shape.\n *\n * Inferring through `{ get(): infer V }` rather than `Signal`\n * sidesteps `Signal`'s nominal/invariance behaviour — the conditional\n * matches structurally on the read side, and `V` is inferred covariantly.\n */\ntype UnwrapSignals = M extends object ? { [K in keyof M]: M[K] extends { get(): infer V } ? V : never } : Empty;\n\n/** Infer the state shape a behavior requires from its deps parameter. */\nexport type InferBehaviorState = DepsOf extends { state: infer M } ? UnwrapSignals : Empty;\n\n/** Infer the context shape a behavior requires from its deps parameter. */\nexport type InferBehaviorContext = DepsOf extends { context: infer M } ? UnwrapSignals : Empty;\n\n/** Infer the config shape a behavior requires from its deps parameter. */\nexport type InferBehaviorConfig = DepsOf extends { config: infer C extends object } ? C : Empty;\n\n/**\n * Recursively intersect a per-behavior projection across the tuple.\n *\n * Iterating over the tuple directly avoids `UnionToIntersection`'s\n * function-contravariance trick, which produces unstable intersections\n * (collapsing concrete fields to `never` or unrelated types) when one of the\n * union members is the empty `{}` fallback.\n */\ntype IntersectBehaviors = Behaviors extends readonly [\n infer First extends AnyBehavior,\n ...infer Rest extends readonly AnyBehavior[],\n]\n ? Apply & IntersectBehaviors\n : Empty;\n\n/**\n * Apply a projection (one of the marker types below) to a single behavior.\n * Encoded as a discriminated dispatch so the recursion above can stay generic\n * and we don't have to write three near-identical recursive types.\n */\ntype Apply = Project extends { kind: 'state' }\n ? InferBehaviorState\n : Project extends { kind: 'context' }\n ? InferBehaviorContext\n : Project extends { kind: 'config' }\n ? InferBehaviorConfig\n : never;\n\ntype StateProjection = { kind: 'state' };\ntype ContextProjection = { kind: 'context' };\ntype ConfigProjection = { kind: 'config' };\n\n/** Resolve the combined state shape from an array of behaviors (intersection of all requirements). */\nexport type ResolveBehaviorState =\n IntersectBehaviors extends infer R extends object ? R : Empty;\n\n/** Resolve the combined context shape from an array of behaviors (intersection of all requirements). */\nexport type ResolveBehaviorContext =\n IntersectBehaviors extends infer R extends object ? R : Empty;\n\n/** Resolve the combined config shape from an array of behaviors (intersection of all requirements). */\nexport type ResolveBehaviorConfig =\n IntersectBehaviors extends infer R extends object ? R : Empty;\n\n/**\n * True if any property in `T` collapsed to `undefined` or `never` — indicating\n * a type conflict from intersecting incompatible behavior requirements.\n *\n * - Required conflicts: `{ v: number } & { v: string }` → `{ v: never }` — caught via `[never] extends [undefined]`\n * - Optional conflicts: `{ v?: number } & { v?: string }` → `{ v?: undefined }` — caught directly\n */\ntype HasConflict = true extends {\n [K in keyof T]: [T[K]] extends [undefined] ? true : never;\n}[keyof T]\n ? true\n : false;\n\n// =============================================================================\n// Composition validation\n// =============================================================================\n\n/**\n * Validate that a behavior composition has no type conflicts.\n * Returns the behaviors tuple if valid, or an error message type if conflicts are detected.\n *\n * State, context, and config are all checked the same way — by intersecting\n * each behavior's requirement and looking for collapsed fields. The\n * intersection-based check applies the same rule to context as to state, so\n * two behaviors that disagree on a context field's type (e.g. `Surface` vs\n * `VideoSurface`) surface a conflict at compose time. The prior subtype-based\n * approach for owners is gone — the unified rule is simpler and catches the\n * cases where two behaviors silently agreed on a wider supertype.\n */\ntype ValidateComposition =\n HasConflict> extends true\n ? 'Error: behaviors have conflicting state types'\n : HasConflict> extends true\n ? 'Error: behaviors have conflicting context types'\n : HasConflict> extends true\n ? 'Error: behaviors have conflicting config types'\n : [...Behaviors];\n\n// =============================================================================\n// Composition\n// =============================================================================\n\n/**\n * A composition of behaviors with shared state and context signal maps.\n */\nexport interface Composition {\n state: StateSignals;\n context: ContextSignals;\n destroy(): Promise;\n}\n\n/**\n * Options for `createComposition`.\n *\n * Composition derives the state and context signal maps from each\n * behavior's declared `stateKeys` / `contextKeys`; `initialState` and\n * `initialContext` seed those signals at creation time. Any unseeded\n * signal starts as `undefined`.\n */\nexport interface CompositionOptions {\n /** Static configuration passed to every behavior. */\n config?: Cfg;\n /** Initial values for state signals — any subset of `keyof S`. */\n initialState?: Partial;\n /** Initial values for context signals — any subset of `keyof C`. */\n initialContext?: Partial;\n}\n\n/**\n * Create a composition from a set of behaviors.\n *\n * Composition unions the behaviors' declared `stateKeys` / `contextKeys`\n * to know which signals to create. Each signal is seeded from\n * `initialState` / `initialContext` when supplied, defaulting to\n * `undefined`. Behaviors are responsible for writing their own slots\n * once their preconditions are met.\n *\n * Cross-behavior type conflicts (e.g. two behaviors disagreeing on a\n * field's type) surface as a compose-time type error via\n * `ValidateComposition`.\n *\n * @example\n * ```ts\n * const composition = createComposition([resolvePresentation, switchVideoTrack], {\n * config: { parsePresentation: parseMultivariantPlaylist, initialBandwidth: 2_000_000 },\n * initialState: { bandwidthState: { fastEstimate: 0, ... } },\n * });\n * ```\n */\n/**\n * Create a typed signal map for a given set of keys, seeded from an\n * optional partial initial value.\n *\n * Pipeline: `Set` dedupes the iterable (insertion order preserved, so\n * first occurrence wins) → `Object.fromEntries` materializes one\n * `signal()` per unique key, seeded from `initial[key]` or `undefined`.\n *\n * Per-key value types live in TypeScript only — at runtime every signal\n * is `Signal`. The boundary cast at the return narrows the wide\n * `Record>` shape to the caller's expected\n * per-key types from `S`.\n *\n * Used by `createComposition` to derive engine state/context maps from\n * the union of behaviors' declared `stateKeys` / `contextKeys`.\n *\n * @example\n * ```ts\n * interface State { count?: number; label?: string }\n * const state = buildSignalMap(['count', 'label'], { count: 5 });\n * state.count.get(); // 5\n * state.label.get(); // undefined\n * ```\n */\nexport function buildSignalMap(\n keys: Iterable,\n initial: Partial\n): { [K in keyof S]-?: Signal } {\n const init = initial as Record;\n const uniqueKeys = new Set(keys);\n return Object.fromEntries([...uniqueKeys].map((key) => [key, signal(init[key])])) as {\n [K in keyof S]-?: Signal;\n };\n}\n\nexport function createComposition(\n behaviors: ValidateComposition,\n options?: CompositionOptions<\n ResolveBehaviorState,\n ResolveBehaviorContext,\n ResolveBehaviorConfig\n >\n): Composition, ResolveBehaviorContext> {\n type S = ResolveBehaviorState;\n type C = ResolveBehaviorContext;\n type Cfg = ResolveBehaviorConfig;\n\n // ValidateComposition is `[...Behaviors]` on success, an error\n // string on conflict. The function body only runs when the call typechecks\n // (i.e. the success case), so iterating as the behavior tuple is sound.\n const validBehaviors = behaviors as unknown as readonly AnyBehavior[];\n\n const state = buildSignalMap(\n validBehaviors.flatMap((b) => b.stateKeys),\n options?.initialState ?? {}\n );\n const context = buildSignalMap(\n validBehaviors.flatMap((b) => b.contextKeys),\n options?.initialContext ?? {}\n );\n\n const deps: BehaviorDeps, ContextSignals, Cfg> = {\n state,\n context,\n config: (options?.config ?? {}) as Cfg,\n };\n const cleanups = validBehaviors.map((behavior) => behavior.setup(deps));\n\n return {\n state,\n context,\n async destroy() {\n const results: (void | Promise)[] = [];\n for (const cleanup of cleanups) {\n if (cleanup == null) continue;\n if (typeof cleanup === 'function') {\n results.push(cleanup());\n } else if ('destroy' in cleanup) {\n results.push(cleanup.destroy());\n }\n }\n await Promise.all(results);\n // Reset every signal to undefined as a final cleanup, matching the\n // prior post-destroy `owners.set({})` semantics. A later stage will\n // move per-signal cleanup into the behaviors that own the writes.\n for (const sig of Object.values(state) as Signal[]) sig.set(undefined);\n for (const sig of Object.values(context) as Signal[]) sig.set(undefined);\n },\n };\n}\n\n// =============================================================================\n// defineBehavior — typed factory with key/param consistency enforcement\n// =============================================================================\n\n/**\n * Compose-time exhaustiveness check.\n *\n * Adds a phantom error tag to the parameter shape when `Keys` does not\n * cover every key in `Slot`. The user's value won't satisfy the phantom\n * field requirement, so TS surfaces the failure at the call site with a\n * descriptive message. When exhaustive, the tag is `Empty` and adds no\n * constraint.\n */\ntype ExhaustiveKeys = [\n keyof Slot,\n] extends [Keys[number]]\n ? Empty\n : { [K in `Error: ${Name}Keys must list every key in the typed slice`]: Exclude };\n\n/**\n * Typed factory for behaviors that enforces single-behavior key/param\n * consistency: declared `stateKeys` must equal `keyof S` (where `S` is\n * inferred from the setup's `state` parameter type), and same for\n * `contextKeys` / `C`.\n *\n * The `const` modifier on `SK` / `CK` captures literal tuples so e.g.\n * `stateKeys: ['preload']` infers as `readonly ['preload']`, no `as\n * const` needed at the call site.\n *\n * Cross-behavior consistency at `createComposition` is unchanged — the\n * existing `IntersectBehaviors` machinery still runs over each\n * behavior's setup param type.\n *\n * @example\n * ```ts\n * export const syncPreload = defineBehavior({\n * stateKeys: ['preload'],\n * contextKeys: ['mediaElement'],\n * setup: ({ state, context }: {\n * state: StateSignals<{ preload?: 'auto' | 'metadata' | 'none' }>;\n * context: ContextSignals<{ mediaElement?: HTMLMediaElement | undefined }>;\n * }) => { ... },\n * });\n * ```\n */\n/**\n * Deps shape for a behavior whose deps slot is empty (no keys). When a\n * slot is empty, the corresponding deps field is optional — callers\n * (typically tests) can omit it, and it defaults to `{}` at runtime via\n * `createComposition`.\n *\n * When a slot has at least one key, the behavior reads `state.foo` /\n * `context.bar` / `config.baz` and we need the field to be required so\n * the access is type-safe.\n */\ntype RequireIfNonEmpty = keyof T extends never\n ? { [K in Key]?: T }\n : { [K in Key]: T };\n\ntype DepsForCfg = RequireIfNonEmpty<\n 'state',\n StateMap\n> &\n RequireIfNonEmpty<'context', ContextMap> &\n RequireIfNonEmpty<'config', Cfg>;\n\nexport function defineBehavior<\n StateMap extends AnySlotMap = Empty,\n ContextMap extends AnySlotMap = Empty,\n Cfg extends object = Empty,\n const SK extends readonly (keyof StateMap)[] = readonly [],\n const CK extends readonly (keyof ContextMap)[] = readonly [],\n R extends BehaviorCleanup = BehaviorCleanup,\n>(\n behavior: {\n stateKeys: SK;\n contextKeys: CK;\n setup: (deps: { state: StateMap; context: ContextMap; config: Cfg }) => R;\n } & ExhaustiveKeys &\n ExhaustiveKeys\n): {\n stateKeys: SK;\n contextKeys: CK;\n setup: (deps: DepsForCfg) => R;\n} {\n // The runtime shape is identical; the cast bridges TS's view of the\n // parameter (config required) to the return view (config optional when\n // Cfg has no keys).\n return behavior as unknown as {\n stateKeys: SK;\n contextKeys: CK;\n setup: (deps: DepsForCfg) => R;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsSA,SAAgB,eACd,MACA,SACoC;CACpC,MAAM,OAAO;CACb,MAAM,aAAa,IAAI,IAAI,IAAI;CAC/B,OAAO,OAAO,YAAY,CAAC,GAAG,UAAU,CAAC,CAAC,KAAK,QAAQ,CAAC,KAAK,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC;AAGlF;AAEA,SAAgB,kBACd,WACA,SAKiF;CAQjF,MAAM,iBAAiB;CAEvB,MAAM,QAAQ,eACZ,eAAe,SAAS,MAAM,EAAE,SAAS,GACzC,SAAS,gBAAgB,CAAC,CAC5B;CACA,MAAM,UAAU,eACd,eAAe,SAAS,MAAM,EAAE,WAAW,GAC3C,SAAS,kBAAkB,CAAC,CAC9B;CAEA,MAAM,OAA8D;EAClE;EACA;EACA,QAAS,SAAS,UAAU,CAAC;CAC/B;CACA,MAAM,WAAW,eAAe,KAAK,aAAa,SAAS,MAAM,IAAI,CAAC;CAEtE,OAAO;EACL;EACA;EACA,MAAM,UAAU;GACd,MAAM,UAAoC,CAAC;GAC3C,KAAK,MAAM,WAAW,UAAU;IAC9B,IAAI,WAAW,MAAM;IACrB,IAAI,OAAO,YAAY,YACrB,QAAQ,KAAK,QAAQ,CAAC;SACjB,IAAI,aAAa,SACtB,QAAQ,KAAK,QAAQ,QAAQ,CAAC;GAElC;GACA,MAAM,QAAQ,IAAI,OAAO;GAIzB,KAAK,MAAM,OAAO,OAAO,OAAO,KAAK,GAAwB,IAAI,IAAI,KAAA,CAAS;GAC9E,KAAK,MAAM,OAAO,OAAO,OAAO,OAAO,GAAwB,IAAI,IAAI,KAAA,CAAS;EAClF;CACF;AACF;AAoEA,SAAgB,eAQd,UAUA;CAIA,OAAO;AAKT"} \ No newline at end of file diff --git a/dist/default/core/composition/share-signals.js b/dist/default/core/composition/share-signals.js new file mode 100644 index 00000000..d0c27bfc --- /dev/null +++ b/dist/default/core/composition/share-signals.js @@ -0,0 +1,39 @@ +//#region src/core/composition/share-signals.ts +/** +* Behavior factory that hands the composition's signal refs to a +* consumer-supplied callback (`config.onSignalsReady`) at setup time. +* +* Generic over `S` and `C` — the caller instantiates with their own +* state/context types, and the callback's parameter shape is fully +* type-driven from those. Suitable for both reads and writes (per-slot +* intent can be expressed by typing captured refs as `Signal` or +* `ReadonlySignal` at the call site). +* +* By default declares no keys; the composition's state/context maps come from +* other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize* +* consumer-input slots that no other behavior produces — a slot the consumer +* writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals +* is the consumer boundary, so it's the natural place to bring those slots into +* existence; readers then treat them as optional. +* +* Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty, +* possibly partial) key arrays don't trip the exhaustiveness check — the +* setup-param state/context shapes describe what the callback receives (the +* full `S` / `C`), not the subset this behavior materializes. +*/ +function makeShareSignals(inputStateKeys = [], inputContextKeys = []) { + return { + stateKeys: inputStateKeys, + contextKeys: inputContextKeys, + setup: ({ state, context, config }) => { + config.onSignalsReady?.({ + state, + context + }); + } + }; +} +//#endregion +export { makeShareSignals }; + +//# sourceMappingURL=share-signals.js.map \ No newline at end of file diff --git a/dist/default/core/composition/share-signals.js.map b/dist/default/core/composition/share-signals.js.map new file mode 100644 index 00000000..d99f6d5e --- /dev/null +++ b/dist/default/core/composition/share-signals.js.map @@ -0,0 +1 @@ +{"version":3,"file":"share-signals.js","names":[],"sources":["../../../../src/core/composition/share-signals.ts"],"sourcesContent":["import type { Behavior, ContextSignals, StateSignals } from './create-composition';\n\n/**\n * Config consumed by the `shareSignals` behavior.\n *\n * The callback fires once during composition setup with the composition's\n * state and context signal refs. Capture them to drive the composition\n * externally (writes) or observe its state (reads).\n *\n * The callback runs while other behaviors are still in their setup phase —\n * for the typical \"capture refs, use later\" pattern this is fine (signal\n * refs are stable identities), but reading inside the callback may yield\n * only initial-seed values rather than what later behaviors write.\n */\nexport interface ShareSignalsConfig {\n onSignalsReady?: (signals: { state: StateSignals; context: ContextSignals }) => void;\n}\n\n/**\n * Behavior factory that hands the composition's signal refs to a\n * consumer-supplied callback (`config.onSignalsReady`) at setup time.\n *\n * Generic over `S` and `C` — the caller instantiates with their own\n * state/context types, and the callback's parameter shape is fully\n * type-driven from those. Suitable for both reads and writes (per-slot\n * intent can be expressed by typing captured refs as `Signal` or\n * `ReadonlySignal` at the call site).\n *\n * By default declares no keys; the composition's state/context maps come from\n * other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize*\n * consumer-input slots that no other behavior produces — a slot the consumer\n * writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals\n * is the consumer boundary, so it's the natural place to bring those slots into\n * existence; readers then treat them as optional.\n *\n * Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty,\n * possibly partial) key arrays don't trip the exhaustiveness check — the\n * setup-param state/context shapes describe what the callback receives (the\n * full `S` / `C`), not the subset this behavior materializes.\n */\nexport function makeShareSignals(\n inputStateKeys: readonly (keyof S)[] = [],\n inputContextKeys: readonly (keyof C)[] = []\n): Behavior, ContextSignals, ShareSignalsConfig> {\n return {\n stateKeys: inputStateKeys,\n contextKeys: inputContextKeys,\n setup: ({ state, context, config }) => {\n config.onSignalsReady?.({ state, context });\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,iBACd,iBAAuC,CAAC,GACxC,mBAAyC,CAAC,GAC8B;CACxE,OAAO;EACL,WAAW;EACX,aAAa;EACb,QAAQ,EAAE,OAAO,SAAS,aAAa;GACrC,OAAO,iBAAiB;IAAE;IAAO;GAAQ,CAAC;EAC5C;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/core/machine.js b/dist/default/core/machine.js new file mode 100644 index 00000000..87f2981b --- /dev/null +++ b/dist/default/core/machine.js @@ -0,0 +1,26 @@ +import { signal, untrack, update } from "./signals/primitives.js"; +//#region src/core/machine.ts +/** +* Provisions the shared mechanics for all machine-like primitives: a snapshot +* signal, an untracked state reader, and a transition function. +* +* Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the +* public API. +*/ +function createMachineCore(initialSnapshot) { + const snapshotSignal = signal(initialSnapshot); + const getState = () => untrack(() => snapshotSignal.get().value); + const transition = (to) => update(snapshotSignal, (current) => ({ + ...current, + value: to + })); + return { + snapshotSignal, + getState, + transition + }; +} +//#endregion +export { createMachineCore }; + +//# sourceMappingURL=machine.js.map \ No newline at end of file diff --git a/dist/default/core/machine.js.map b/dist/default/core/machine.js.map new file mode 100644 index 00000000..d0fde5ef --- /dev/null +++ b/dist/default/core/machine.js.map @@ -0,0 +1 @@ +{"version":3,"file":"machine.js","names":[],"sources":["../../../src/core/machine.ts"],"sourcesContent":["import type { ReadonlySignal } from './signals/primitives';\nimport { signal, untrack, update } from './signals/primitives';\n\n// =============================================================================\n// Shared snapshot type\n// =============================================================================\n\n/**\n * Base snapshot for all machine-like primitives (Actors and Reactors).\n * Carries only the finite state value. Actors extend this with `context`.\n */\nexport interface MachineSnapshot {\n value: State;\n}\n\n// =============================================================================\n// Shared interface\n// =============================================================================\n\n/**\n * Shared interface for all machine-like primitives.\n * Both Actors (message-driven) and Reactors (signal-driven) implement this.\n */\nexport interface Machine> {\n readonly snapshot: ReadonlySignal;\n destroy(): void;\n}\n\n// =============================================================================\n// Shared core factory\n// =============================================================================\n\n/**\n * Provisions the shared mechanics for all machine-like primitives: a snapshot\n * signal, an untracked state reader, and a transition function.\n *\n * Internal — consumed by `createMachineActor` and `createMachineReactor`. Not part of the\n * public API.\n */\nexport function createMachineCore>(\n initialSnapshot: Snapshot\n) {\n const snapshotSignal = signal(initialSnapshot);\n const getState = (): FullState => untrack(() => snapshotSignal.get().value);\n const transition = (to: FullState): void => update(snapshotSignal, (current) => ({ ...current, value: to }));\n return { snapshotSignal, getState, transition };\n}\n"],"mappings":";;;;;;;;;AAuCA,SAAgB,kBACd,iBACA;CACA,MAAM,iBAAiB,OAAO,eAAe;CAC7C,MAAM,iBAA4B,cAAc,eAAe,IAAI,CAAC,CAAC,KAAK;CAC1E,MAAM,cAAc,OAAwB,OAAO,iBAAiB,aAAa;EAAE,GAAG;EAAS,OAAO;CAAG,EAAE;CAC3G,OAAO;EAAE;EAAgB;EAAU;CAAW;AAChD"} \ No newline at end of file diff --git a/dist/default/core/reactors/create-machine-reactor.js b/dist/default/core/reactors/create-machine-reactor.js new file mode 100644 index 00000000..dc8310d4 --- /dev/null +++ b/dist/default/core/reactors/create-machine-reactor.js @@ -0,0 +1,83 @@ +import { untrack } from "../signals/primitives.js"; +import { effect } from "../signals/effect.js"; +import { createMachineCore } from "../machine.js"; +//#region src/core/reactors/create-machine-reactor.ts +const toArray = (x) => x === void 0 ? [] : Array.isArray(x) ? x : [x]; +/** +* Creates a reactive Reactor from a declarative definition. +* +* A Reactor is driven by subscriptions to external signals rather than +* imperative messages. Each state holds an array of effect functions — +* every element becomes one independent `effect()` call gated on that state, +* with its own dependency tracking and cleanup lifecycle. +* +* `'destroying'` and `'destroyed'` are always implicit terminal states. +* `destroy()` transitions through both in sequence: `'destroying'` first (for +* potential async teardown in a future extension), then immediately `'destroyed'` +* for the synchronous base case. Active effect cleanups fire via disposal. +* +* @example +* const reactor = createMachineReactor({ +* initial: 'waiting', +* monitor: () => srcSignal.get() ? 'active' : 'waiting', +* states: { +* active: { +* // entry: runs once on state entry; fn body is automatically untracked. +* entry: () => listen(el, 'play', handler), +* // effects: re-runs whenever tracked signals change. +* effects: () => { currentTimeSignal.get(); return cleanup; }, +* }, +* waiting: {}, +* } +* }); +*/ +function createMachineReactor(def) { + const { snapshotSignal, getState, transition } = createMachineCore({ value: def.initial }); + const effectDisposals = []; + const wrapResult = (result) => { + if (!result) return void 0; + if (typeof result === "function") return result; + return () => result.abort(); + }; + const untracked = (baseCall) => () => untrack(baseCall); + const isTerminal = (snapshot) => snapshot.value === "destroying" || snapshot.value === "destroyed"; + const descriptors = [...toArray(def.monitor).map((fn) => ({ + fn: () => { + const target = fn(); + if (target !== getState()) transition(target); + }, + shouldSkip: isTerminal + })), ...Object.entries(def.states).flatMap(([state, stateDef]) => { + const isNotState = (snapshot) => snapshot.value !== state; + return [...toArray(stateDef.entry).map((fn) => ({ + fn, + shouldSkip: isNotState, + toFnCall: untracked + })), ...toArray(stateDef.effects).map((fn) => ({ + fn, + shouldSkip: isNotState + }))]; + })]; + const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }) => effect(() => { + if (shouldSkip(snapshotSignal.get())) return; + const baseCall = () => fn(); + return wrapResult(toFnCall(baseCall)()); + }); + effectDisposals.push(...descriptors.map(toEffect)); + return { + get snapshot() { + return snapshotSignal; + }, + destroy() { + const state = getState(); + if (state === "destroying" || state === "destroyed") return; + transition("destroying"); + transition("destroyed"); + for (const dispose of effectDisposals) dispose(); + } + }; +} +//#endregion +export { createMachineReactor }; + +//# sourceMappingURL=create-machine-reactor.js.map \ No newline at end of file diff --git a/dist/default/core/reactors/create-machine-reactor.js.map b/dist/default/core/reactors/create-machine-reactor.js.map new file mode 100644 index 00000000..01d51edb --- /dev/null +++ b/dist/default/core/reactors/create-machine-reactor.js.map @@ -0,0 +1 @@ +{"version":3,"file":"create-machine-reactor.js","names":[],"sources":["../../../../src/core/reactors/create-machine-reactor.ts"],"sourcesContent":["import type { Machine, MachineSnapshot } from '../machine';\nimport { createMachineCore } from '../machine';\nimport { effect } from '../signals/effect';\nimport { untrack } from '../signals/primitives';\n\n// =============================================================================\n// Definition types\n// =============================================================================\n\n/**\n * A reactive state-deriving function used in the `monitor` field.\n *\n * Returns the target state the reactor should be in. Any signals read inside\n * the fn body create reactive dependencies — the framework re-evaluates it when\n * those signals change and automatically calls `transition()` when the returned\n * state differs from the current one.\n */\nexport type ReactorDeriveFn = () => State;\n\n/**\n * An effect function used in reactor `entry` and `effects` blocks.\n *\n * May return a cleanup function that runs before each re-evaluation and on\n * state exit (including destroy).\n */\nexport type ReactorEffectFn = () => (() => void) | { abort(): void } | void;\n\n/**\n * Per-state effect grouping for a single reactor state.\n *\n * - `entry` effects run once on state entry. The fn body is automatically\n * untracked — no `untrack()` calls are needed inside. Use this for\n * one-time setup: reading current values, attaching event listeners, etc.\n * - `effects` run on state entry and re-run whenever a signal read inside\n * the fn body changes. Use `untrack()` for reads you do not want to track.\n * Use this for work that must stay in sync with reactive state.\n *\n * Both are optional; pass `{}` for states with no effects.\n */\nexport type ReactorStateDefinition = {\n entry?: ReactorEffectFn | ReactorEffectFn[];\n effects?: ReactorEffectFn | ReactorEffectFn[];\n};\n\n/**\n * Full reactor definition passed to `createMachineReactor`.\n *\n * `State` is the set of domain-meaningful states. `'destroying'` and\n * `'destroyed'` are always added by the framework as implicit terminal states —\n * do not include them here.\n */\nexport type ReactorDefinition = {\n /** Initial state. */\n initial: State;\n /**\n * Reactive state derivation. Registered before per-state effects — the\n * ordering guarantee ensures transitions fired here take effect before\n * per-state effects re-evaluate in the same flush.\n */\n monitor?: ReactorDeriveFn | ReactorDeriveFn[];\n /**\n * Per-state effect groupings. Every valid state must be declared — pass `{}`\n * for states with no effects. `entry` and `effects` each become independent\n * `effect()` calls gated on that state, with their own cleanup lifecycles.\n */\n states: Record;\n};\n\n// =============================================================================\n// Live reactor interface\n// =============================================================================\n\n/** Live reactor instance returned by `createMachineReactor`. */\nexport type Reactor = Machine>;\n\n// =============================================================================\n// Implementation helpers\n// =============================================================================\n\nconst toArray = (x: T | T[] | undefined): T[] => (x === undefined ? [] : Array.isArray(x) ? x : [x]);\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Creates a reactive Reactor from a declarative definition.\n *\n * A Reactor is driven by subscriptions to external signals rather than\n * imperative messages. Each state holds an array of effect functions —\n * every element becomes one independent `effect()` call gated on that state,\n * with its own dependency tracking and cleanup lifecycle.\n *\n * `'destroying'` and `'destroyed'` are always implicit terminal states.\n * `destroy()` transitions through both in sequence: `'destroying'` first (for\n * potential async teardown in a future extension), then immediately `'destroyed'`\n * for the synchronous base case. Active effect cleanups fire via disposal.\n *\n * @example\n * const reactor = createMachineReactor({\n * initial: 'waiting',\n * monitor: () => srcSignal.get() ? 'active' : 'waiting',\n * states: {\n * active: {\n * // entry: runs once on state entry; fn body is automatically untracked.\n * entry: () => listen(el, 'play', handler),\n * // effects: re-runs whenever tracked signals change.\n * effects: () => { currentTimeSignal.get(); return cleanup; },\n * },\n * waiting: {},\n * }\n * });\n */\nexport function createMachineReactor(\n def: ReactorDefinition\n): Reactor {\n type FullState = State | 'destroying' | 'destroyed';\n\n const { snapshotSignal, getState, transition } = createMachineCore>({\n value: def.initial as FullState,\n });\n\n const effectDisposals: Array<() => void> = [];\n\n const wrapResult = (result: ReturnType) => {\n if (!result) return undefined;\n if (typeof result === 'function') return result;\n return () => result.abort();\n };\n\n type EffectCall = () => ReturnType;\n\n type EffectDescriptor = {\n fn: ReactorEffectFn;\n shouldSkip: (snapshot: { value: FullState }) => boolean;\n toFnCall?: (baseCall: EffectCall) => EffectCall;\n };\n\n const untracked: EffectDescriptor['toFnCall'] = (baseCall) => () => untrack(baseCall);\n\n const isTerminal = (snapshot: { value: FullState }) =>\n snapshot.value === 'destroying' || snapshot.value === 'destroyed';\n\n // `monitor` descriptors are built first — the ordering guarantee ensures\n // transitions they trigger take effect before per-state effects re-evaluate\n // in the same flush. See the comment on effect registration order in the\n // previous implementation for full details.\n const descriptors: EffectDescriptor[] = [\n ...toArray(def.monitor).map((fn) => ({\n fn: () => {\n const target = fn();\n if (target !== (getState() as State)) transition(target as FullState);\n },\n shouldSkip: isTerminal,\n })),\n ...(Object.entries(def.states) as Array<[State, ReactorStateDefinition]>).flatMap(([state, stateDef]) => {\n const isNotState = (snapshot: { value: FullState }) => snapshot.value !== state;\n return [\n ...toArray(stateDef.entry).map((fn) => ({ fn, shouldSkip: isNotState, toFnCall: untracked })),\n ...toArray(stateDef.effects).map((fn) => ({ fn, shouldSkip: isNotState })),\n ];\n }),\n ];\n\n const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }: EffectDescriptor) =>\n effect(() => {\n const snapshot = snapshotSignal.get();\n if (shouldSkip(snapshot)) return;\n const baseCall = () => fn();\n return wrapResult(toFnCall(baseCall)());\n });\n\n effectDisposals.push(...descriptors.map(toEffect));\n\n return {\n get snapshot() {\n return snapshotSignal;\n },\n\n destroy(): void {\n const state = getState();\n if (state === 'destroying' || state === 'destroyed') return;\n // Two-step teardown: transition through 'destroying' first to leave room\n // for async teardown in a future extension, then immediately 'destroyed'\n // for the synchronous base case. Active effect cleanups fire via disposal.\n transition('destroying');\n transition('destroyed');\n for (const dispose of effectDisposals) dispose();\n },\n };\n}\n"],"mappings":";;;;AA+EA,MAAM,WAAc,MAAiC,MAAM,KAAA,IAAY,CAAC,IAAI,MAAM,QAAQ,CAAC,IAAI,IAAI,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCrG,SAAgB,qBACd,KAC6C;CAG7C,MAAM,EAAE,gBAAgB,UAAU,eAAe,kBAAyD,EACxG,OAAO,IAAI,QACb,CAAC;CAED,MAAM,kBAAqC,CAAC;CAE5C,MAAM,cAAc,WAAwC;EAC1D,IAAI,CAAC,QAAQ,OAAO,KAAA;EACpB,IAAI,OAAO,WAAW,YAAY,OAAO;EACzC,aAAa,OAAO,MAAM;CAC5B;CAUA,MAAM,aAA2C,mBAAmB,QAAQ,QAAQ;CAEpF,MAAM,cAAc,aAClB,SAAS,UAAU,gBAAgB,SAAS,UAAU;CAMxD,MAAM,cAAkC,CACtC,GAAG,QAAQ,IAAI,OAAO,CAAC,CAAC,KAAK,QAAQ;EACnC,UAAU;GACR,MAAM,SAAS,GAAG;GAClB,IAAI,WAAY,SAAS,GAAa,WAAW,MAAmB;EACtE;EACA,YAAY;CACd,EAAE,GACF,GAAI,OAAO,QAAQ,IAAI,MAAM,CAAC,CAA4C,SAAS,CAAC,OAAO,cAAc;EACvG,MAAM,cAAc,aAAmC,SAAS,UAAU;EAC1E,OAAO,CACL,GAAG,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,QAAQ;GAAE;GAAI,YAAY;GAAY,UAAU;EAAU,EAAE,GAC5F,GAAG,QAAQ,SAAS,OAAO,CAAC,CAAC,KAAK,QAAQ;GAAE;GAAI,YAAY;EAAW,EAAE,CAC3E;CACF,CAAC,CACH;CAEA,MAAM,YAAY,EAAE,IAAI,YAAY,YAAY,aAAa,eAC3D,aAAa;EAEX,IAAI,WADa,eAAe,IACV,CAAC,GAAG;EAC1B,MAAM,iBAAiB,GAAG;EAC1B,OAAO,WAAW,SAAS,QAAQ,CAAC,CAAC,CAAC;CACxC,CAAC;CAEH,gBAAgB,KAAK,GAAG,YAAY,IAAI,QAAQ,CAAC;CAEjD,OAAO;EACL,IAAI,WAAW;GACb,OAAO;EACT;EAEA,UAAgB;GACd,MAAM,QAAQ,SAAS;GACvB,IAAI,UAAU,gBAAgB,UAAU,aAAa;GAIrD,WAAW,YAAY;GACvB,WAAW,WAAW;GACtB,KAAK,MAAM,WAAW,iBAAiB,QAAQ;EACjD;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/core/signals/effect.js b/dist/default/core/signals/effect.js new file mode 100644 index 00000000..aa716c75 --- /dev/null +++ b/dist/default/core/signals/effect.js @@ -0,0 +1,41 @@ +import { Signal } from "signal-polyfill"; +//#region src/core/signals/effect.ts +const pending = /* @__PURE__ */ new Set(); +const watcher = new Signal.subtle.Watcher(() => { + queueMicrotask(runPending); +}); +function runPending() { + for (const c of watcher.getPending()) pending.add(c); + watcher.watch(); + for (const c of pending) { + pending.delete(c); + c.get(); + } +} +/** +* 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. +*/ +function effect(fn) { + let cleanup; + const c = new Signal.Computed(() => { + if (typeof cleanup === "function") cleanup(); + cleanup = fn(); + }); + watcher.watch(c); + c.get(); + return () => { + watcher.unwatch(c); + if (typeof cleanup === "function") cleanup(); + }; +} +//#endregion +export { effect }; + +//# sourceMappingURL=effect.js.map \ No newline at end of file diff --git a/dist/default/core/signals/effect.js.map b/dist/default/core/signals/effect.js.map new file mode 100644 index 00000000..09197611 --- /dev/null +++ b/dist/default/core/signals/effect.js.map @@ -0,0 +1 @@ +{"version":3,"file":"effect.js","names":[],"sources":["../../../../src/core/signals/effect.ts"],"sourcesContent":["import { Signal } from 'signal-polyfill';\n\n// Computeds waiting to re-run after their dependencies changed.\nconst pending = new Set>();\n\nconst watcher = new Signal.subtle.Watcher(() => {\n queueMicrotask(runPending);\n});\n\nfunction runPending() {\n for (const c of watcher.getPending()) {\n pending.add(c as Signal.Computed);\n }\n watcher.watch(); // re-arm before running effects, in case they write signals\n for (const c of pending) {\n pending.delete(c);\n c.get(); // re-run the effect body\n }\n}\n\n/**\n * Run a side effect whenever its signal dependencies change.\n *\n * Executes immediately (synchronous initial run), then re-runs on the next\n * microtask after any dependency changes. If the callback returns a function,\n * it is called before each re-run and when the effect is stopped — the same\n * cleanup contract as Preact Signals, Maverick Signals, and Svelte 5 $effect.\n *\n * Returns a cleanup function that stops the effect.\n */\nexport function effect(fn: () => (() => void) | void): () => void {\n let cleanup: (() => void) | void;\n const c = new Signal.Computed(() => {\n if (typeof cleanup === 'function') cleanup();\n cleanup = fn();\n });\n watcher.watch(c);\n c.get(); // initial run\n return () => {\n watcher.unwatch(c);\n if (typeof cleanup === 'function') cleanup();\n };\n}\n"],"mappings":";;AAGA,MAAM,0BAAU,IAAI,IAA2B;AAE/C,MAAM,UAAU,IAAI,OAAO,OAAO,cAAc;CAC9C,eAAe,UAAU;AAC3B,CAAC;AAED,SAAS,aAAa;CACpB,KAAK,MAAM,KAAK,QAAQ,WAAW,GACjC,QAAQ,IAAI,CAA0B;CAExC,QAAQ,MAAM;CACd,KAAK,MAAM,KAAK,SAAS;EACvB,QAAQ,OAAO,CAAC;EAChB,EAAE,IAAI;CACR;AACF;;;;;;;;;;;AAYA,SAAgB,OAAO,IAA2C;CAChE,IAAI;CACJ,MAAM,IAAI,IAAI,OAAO,eAAe;EAClC,IAAI,OAAO,YAAY,YAAY,QAAQ;EAC3C,UAAU,GAAG;CACf,CAAC;CACD,QAAQ,MAAM,CAAC;CACf,EAAE,IAAI;CACN,aAAa;EACX,QAAQ,QAAQ,CAAC;EACjB,IAAI,OAAO,YAAY,YAAY,QAAQ;CAC7C;AACF"} \ No newline at end of file diff --git a/dist/default/core/signals/primitives.js b/dist/default/core/signals/primitives.js new file mode 100644 index 00000000..850a7ac9 --- /dev/null +++ b/dist/default/core/signals/primitives.js @@ -0,0 +1,54 @@ +import { Signal } from "signal-polyfill"; +//#region src/core/signals/primitives.ts +/** Read a signal value without tracking it as a dependency. */ +const untrack = Signal.subtle.untrack; +/** +* Read a signal's current value without tracking it as a dependency. Sugar +* for `untrack(() => signal.get())` to reduce boilerplate at single-read +* sites. Structurally typed to accept any signal-like (Signal, Computed, +* ReadonlySignal). +* +* Accepts an optional `transform` to project the value in the same call; +* the default is the identity function so the single-arg form returns `T` +* unchanged. +* +* @example +* const value = peek(someSignal); +* const id = peek(presentationSignal, (p) => p?.id); +*/ +function peek(source, transform = (v) => v) { + return untrack(() => transform(source.get())); +} +/** Create a writable reactive value. */ +function signal(initialValue, options) { + return new Signal.State(initialValue, options); +} +/** Create a computed reactive value. */ +function computed(fn, options) { + return new Signal.Computed(fn, options); +} +function update(signal, updater) { + const current = untrack(() => signal.get()); + if (typeof updater === "function") signal.set(updater(current)); + else signal.set({ + ...current, + ...updater + }); +} +/** +* Read every signal in a map and return a plain object snapshot. Each read +* tracks in the surrounding Computed/Effect — equivalent to calling `.get()` +* on a single `Signal` over the merged shape. +* +* Convenience for behaviors that pass whole state/context snapshots to pure +* helpers; prefer per-field reads when only a few fields are needed. +*/ +function snapshot(map) { + const out = {}; + for (const key in map) out[key] = map[key].get(); + return out; +} +//#endregion +export { computed, peek, signal, snapshot, untrack, update }; + +//# sourceMappingURL=primitives.js.map \ No newline at end of file diff --git a/dist/default/core/signals/primitives.js.map b/dist/default/core/signals/primitives.js.map new file mode 100644 index 00000000..97083981 --- /dev/null +++ b/dist/default/core/signals/primitives.js.map @@ -0,0 +1 @@ +{"version":3,"file":"primitives.js","names":["SignalNS"],"sources":["../../../../src/core/signals/primitives.ts"],"sourcesContent":["import { Signal as SignalNS } from 'signal-polyfill';\n\n/** Read a signal value without tracking it as a dependency. */\nexport const untrack: (fn: () => T) => T = SignalNS.subtle.untrack;\n\n/**\n * Read a signal's current value without tracking it as a dependency. Sugar\n * for `untrack(() => signal.get())` to reduce boilerplate at single-read\n * sites. Structurally typed to accept any signal-like (Signal, Computed,\n * ReadonlySignal).\n *\n * Accepts an optional `transform` to project the value in the same call;\n * the default is the identity function so the single-arg form returns `T`\n * unchanged.\n *\n * @example\n * const value = peek(someSignal);\n * const id = peek(presentationSignal, (p) => p?.id);\n */\nexport function peek(source: { get(): T }, transform: (value: T) => R = (v: T) => v as unknown as R): R {\n return untrack(() => transform(source.get()));\n}\n\n/** A writable reactive value (read + write). */\nexport type Signal = SignalNS.State;\n\n/** A derived reactive value that re-evaluates when its dependencies change (read-only). */\nexport type Computed = SignalNS.Computed;\n\n/** A read-only view of a reactive value. */\nexport type ReadonlySignal = Omit, 'set'>;\n\nexport interface SignalOptions {\n equals?: (t: T, t2: T) => boolean;\n}\n\n/** Create a writable reactive value. */\nexport function signal(initialValue: T, options?: SignalOptions): Signal {\n return new SignalNS.State(initialValue, options as SignalNS.Options);\n}\n\n/** Create a computed reactive value. */\nexport function computed(fn: () => T, options?: SignalOptions): Computed {\n return new SignalNS.Computed(fn, options as SignalNS.Options);\n}\n\n/**\n * Update a writable signal. Two forms:\n *\n * - **Updater function** `(current) => next`. Works for any signal type,\n * including `Signal` — handle undefined in the updater.\n * - **Partial object** to merge into the current state. Requires\n * `T extends object`.\n *\n * @example\n * update(state, { playbackRate: 2 });\n * update(state, (s) => ({ ...s, count: s.count + 1 }));\n * update(maybeUndefinedSignal, (current) => current ?? defaultValue);\n */\nexport function update(signal: Signal, updater: (current: T) => T): void;\nexport function update(signal: Signal, updater: Partial): void;\nexport function update(signal: Signal, updater: ((current: T) => T) | object): void {\n const current = untrack(() => signal.get());\n if (typeof updater === 'function') {\n signal.set((updater as (current: T) => T)(current));\n } else {\n // Partial form — `T extends object` enforced by the public overload.\n signal.set({ ...(current as object), ...updater } as T);\n }\n}\n\n/**\n * Equality comparator for objects with an optional `id` field. Designed for\n * use as a `computed` `equals` option when reacting to identity changes\n * (Ham-shaped objects, JSON-API-shaped resources) while filtering internal\n * updates that preserve the id.\n *\n * Handles undefined inputs symmetrically: both undefined → equal; one\n * undefined → different.\n *\n * @example\n * const presentationById = computed(() => state.presentation.get(), {\n * equals: equalsById,\n * });\n */\nexport function equalsById(a: T | undefined, b: T | undefined): boolean {\n return a?.id === b?.id;\n}\n\n/**\n * Read every signal in a map and return a plain object snapshot. Each read\n * tracks in the surrounding Computed/Effect — equivalent to calling `.get()`\n * on a single `Signal` over the merged shape.\n *\n * Convenience for behaviors that pass whole state/context snapshots to pure\n * helpers; prefer per-field reads when only a few fields are needed.\n */\nexport function snapshot>>(\n map: M\n): { [K in keyof M]: M[K] extends { get(): infer V } ? V : never } {\n const out = {} as { [K in keyof M]: M[K] extends { get(): infer V } ? V : never };\n for (const key in map) {\n out[key] = map[key]!.get() as never;\n }\n return out;\n}\n"],"mappings":";;;AAGA,MAAa,UAAiCA,OAAS,OAAO;;;;;;;;;;;;;;;AAgB9D,SAAgB,KAAe,QAAsB,aAA8B,MAAS,GAAsB;CAChH,OAAO,cAAc,UAAU,OAAO,IAAI,CAAC,CAAC;AAC9C;;AAgBA,SAAgB,OAAU,cAAiB,SAAuC;CAChF,OAAO,IAAIA,OAAS,MAAM,cAAc,OAA8B;AACxE;;AAGA,SAAgB,SAAY,IAAa,SAAyC;CAChF,OAAO,IAAIA,OAAS,SAAS,IAAI,OAA8B;AACjE;AAiBA,SAAgB,OAAU,QAAmB,SAA6C;CACxF,MAAM,UAAU,cAAc,OAAO,IAAI,CAAC;CAC1C,IAAI,OAAO,YAAY,YACrB,OAAO,IAAK,QAA8B,OAAO,CAAC;MAGlD,OAAO,IAAI;EAAE,GAAI;EAAoB,GAAG;CAAQ,CAAM;AAE1D;;;;;;;;;AA4BA,SAAgB,SACd,KACiE;CACjE,MAAM,MAAM,CAAC;CACb,KAAK,MAAM,OAAO,KAChB,IAAI,OAAO,IAAI,IAAI,CAAE,IAAI;CAE3B,OAAO;AACT"} \ No newline at end of file diff --git a/dist/default/core/tasks/task.js b/dist/default/core/tasks/task.js new file mode 100644 index 00000000..9002f7fe --- /dev/null +++ b/dist/default/core/tasks/task.js @@ -0,0 +1,191 @@ +import { anyAbortSignal } from "@videojs/utils/events"; +import { generateId } from "@videojs/utils/string"; +//#region src/core/tasks/task.ts +/** +* Generic reusable task that wraps an async run function. +* +* Owns its own AbortController so it can always be aborted independently. +* Optionally composes an external AbortSignal so that a parent's cancellation +* propagates into the task's work without requiring the caller to track the +* task separately. +* +* Ordering guarantee: `value` is written before `status` transitions to `'done'`; +* `error` is written before `status` transitions to `'error'`. Any reader +* observing `status === 'done'` is guaranteed `value` is already present. +*/ +var Task = class { + id; + #runFn; + #abortController = new AbortController(); + #signal; + #status = "pending"; + #value = void 0; + #error = void 0; + constructor(runFn, config) { + this.#runFn = runFn; + const rawId = config?.id; + this.id = typeof rawId === "function" ? rawId() : rawId ?? generateId(); + this.#signal = config?.signal ? anyAbortSignal([this.#abortController.signal, config.signal]) : this.#abortController.signal; + } + get status() { + return this.#status; + } + get value() { + return this.#value; + } + get error() { + return this.#error; + } + async run() { + this.#status = "running"; + try { + const result = await this.#runFn(this.#signal); + this.#value = result; + this.#status = "done"; + return result; + } catch (e) { + this.#error = e; + this.#status = "error"; + throw e; + } + } + abort() { + this.#abortController.abort(); + } +}; +/** +* Runs tasks concurrently, deduplicated by task id. +* +* If a task with a given id is already in flight, subsequent schedule() calls +* for that id are silently ignored until the first completes. Tasks are stored +* so abortAll() can cancel any in-flight work (e.g. on engine cleanup). +*/ +var ConcurrentRunner = class { + #pending = /* @__PURE__ */ new Map(); + #settled = Promise.resolve(); + #resolveSettled = null; + #destroyed = false; + schedule(task) { + if (this.#destroyed) return Promise.resolve(); + const existing = this.#pending.get(task.id); + if (existing) return existing.promise; + if (this.#pending.size === 0) this.#settled = new Promise((resolve) => { + this.#resolveSettled = resolve; + }); + const promise = task.run(); + promise.catch(() => {}); + const cleanup = () => { + this.#pending.delete(task.id); + if (this.#pending.size === 0) { + this.#resolveSettled?.(); + this.#resolveSettled = null; + } + }; + promise.then(cleanup, cleanup); + this.#pending.set(task.id, { + task, + promise + }); + return promise; + } + /** + * Registers a callback to fire when all currently in-flight tasks settle. + * If the runner is already idle, the callback is never called. If abortAll() + * is called before the batch settles, the callback is superseded and silently + * dropped — no stale callbacks, no generation token required by the caller. + */ + whenSettled(callback) { + if (this.#pending.size === 0) return; + const captured = this.#settled; + captured.then(() => { + if (this.#settled !== captured) return; + callback(); + }, () => {}); + } + abortAll() { + for (const { task } of this.#pending.values()) task.abort(); + this.#pending.clear(); + this.#resolveSettled?.(); + this.#resolveSettled = null; + this.#settled = Promise.resolve(); + } + destroy() { + this.#destroyed = true; + this.abortAll(); + } +}; +/** +* Runs tasks one at a time in submission order. +* +* Each schedule() call returns a Promise that resolves or rejects with the +* task's result when it is eventually executed. Tasks wait in queue until the +* prior task completes. +* +* Serialization is achieved by chaining each task's run() onto the tail of a +* shared promise chain — no explicit queue or drain loop needed. +* +* abortAll() aborts all pending (not yet started) tasks and the currently +* in-flight task. Pending tasks still run briefly but receive an aborted +* signal and are expected to exit early. +*/ +var SerialRunner = class { + #chain = Promise.resolve(); + #pending = /* @__PURE__ */ new Set(); + #current = null; + #destroyed = false; + schedule(task) { + if (this.#destroyed) return Promise.resolve(); + const t = task; + this.#pending.add(t); + const result = this.#chain.then(() => { + this.#pending.delete(t); + this.#current = t; + return task.run(); + }).finally(() => { + this.#current = null; + }); + this.#chain = result.then(() => {}, () => {}); + return result; + } + /** + * A promise that resolves when all currently-scheduled tasks have settled. + * Use the reference as a generation token: capture it after scheduling a + * batch, then check identity in the resolution callback to detect whether + * a subsequent abortAll() + new batch has superseded this one. + */ + get settled() { + return this.#chain; + } + /** + * Registers a callback to fire when all currently-pending tasks settle. + * If the runner is already idle (no pending or running tasks), the callback + * is never called. If new tasks are scheduled before the current batch + * settles, the callback is superseded and silently dropped — no stale + * callbacks, no generation token required by the caller. + */ + whenSettled(callback) { + if (this.#pending.size === 0 && this.#current === null) return; + const currentChain = this.#chain; + currentChain.then(() => { + if (this.#chain !== currentChain) return; + callback(); + }, () => {}); + } + /** Aborts and clears queued tasks without touching the in-flight task. */ + abortPending() { + for (const task of this.#pending) task.abort(); + this.#pending.clear(); + } + abortAll() { + this.abortPending(); + this.#current?.abort(); + } + destroy() { + this.#destroyed = true; + this.abortAll(); + } +}; +//#endregion +export { ConcurrentRunner, SerialRunner, Task }; + +//# sourceMappingURL=task.js.map \ No newline at end of file diff --git a/dist/default/core/tasks/task.js.map b/dist/default/core/tasks/task.js.map new file mode 100644 index 00000000..bfce48cf --- /dev/null +++ b/dist/default/core/tasks/task.js.map @@ -0,0 +1 @@ +{"version":3,"file":"task.js","names":["#runFn","#abortController","#signal","#status","#value","#error","#pending","#destroyed","#settled","#resolveSettled","#chain","#current"],"sources":["../../../../src/core/tasks/task.ts"],"sourcesContent":["import { anyAbortSignal } from '@videojs/utils/events';\nimport { generateId } from '@videojs/utils/string';\n\n// =============================================================================\n// DeepReadonly\n// =============================================================================\n\n/** Recursively marks all properties as readonly. */\nexport type DeepReadonly = T extends (infer U)[]\n ? ReadonlyArray>\n : T extends object\n ? { readonly [K in keyof T]: DeepReadonly }\n : T;\n\n// =============================================================================\n// Task\n// =============================================================================\n\nexport type TaskStatus = 'pending' | 'running' | 'done' | 'error';\n\n/**\n * Configuration for a Task.\n */\nexport interface TaskConfig {\n /**\n * Identifier for this task.\n * - string: used as-is\n * - () => string: called once at construction time\n * - undefined: a unique ID is generated via generateId()\n */\n id?: string | (() => string);\n\n /**\n * Optional external AbortSignal to compose with the task's internal one.\n * The task's work is aborted when either the internal controller (via abort())\n * or this external signal fires — whichever comes first.\n */\n signal?: AbortSignal;\n}\n\n/**\n * Minimal contract for a schedulable unit of async work.\n */\nexport interface TaskLike {\n readonly id: string;\n readonly status: TaskStatus;\n readonly value: DeepReadonly | undefined;\n readonly error: DeepReadonly | undefined;\n run(): Promise;\n abort(): void;\n}\n\n/**\n * Generic reusable task that wraps an async run function.\n *\n * Owns its own AbortController so it can always be aborted independently.\n * Optionally composes an external AbortSignal so that a parent's cancellation\n * propagates into the task's work without requiring the caller to track the\n * task separately.\n *\n * Ordering guarantee: `value` is written before `status` transitions to `'done'`;\n * `error` is written before `status` transitions to `'error'`. Any reader\n * observing `status === 'done'` is guaranteed `value` is already present.\n */\nexport class Task implements TaskLike {\n readonly id: string;\n readonly #runFn: (signal: AbortSignal) => Promise;\n readonly #abortController = new AbortController();\n readonly #signal: AbortSignal;\n\n #status: TaskStatus = 'pending';\n #value: TValue | undefined = undefined;\n #error: TError | undefined = undefined;\n\n constructor(runFn: (signal: AbortSignal) => Promise, config?: TaskConfig) {\n this.#runFn = runFn;\n const rawId = config?.id;\n this.id = typeof rawId === 'function' ? rawId() : (rawId ?? generateId());\n this.#signal = config?.signal\n ? anyAbortSignal([this.#abortController.signal, config.signal])\n : this.#abortController.signal;\n }\n\n get status(): TaskStatus {\n return this.#status;\n }\n\n get value(): DeepReadonly | undefined {\n return this.#value as DeepReadonly | undefined;\n }\n\n get error(): DeepReadonly | undefined {\n return this.#error as DeepReadonly | undefined;\n }\n\n async run(): Promise {\n this.#status = 'running';\n try {\n const result = await this.#runFn(this.#signal);\n this.#value = result; // value before status — ordering guarantee\n this.#status = 'done';\n return result;\n } catch (e) {\n this.#error = e as TError; // error before status — ordering guarantee\n this.#status = 'error';\n throw e;\n }\n }\n\n abort(): void {\n this.#abortController.abort();\n }\n}\n\n// =============================================================================\n// ConcurrentRunner\n// =============================================================================\n\n/**\n * Runs tasks concurrently, deduplicated by task id.\n *\n * If a task with a given id is already in flight, subsequent schedule() calls\n * for that id are silently ignored until the first completes. Tasks are stored\n * so abortAll() can cancel any in-flight work (e.g. on engine cleanup).\n */\nexport class ConcurrentRunner {\n readonly #pending = new Map; promise: Promise }>();\n #settled: Promise = Promise.resolve();\n #resolveSettled: (() => void) | null = null;\n #destroyed = false;\n\n schedule(task: TaskLike): Promise {\n if (this.#destroyed) return Promise.resolve() as Promise;\n const existing = this.#pending.get(task.id);\n if (existing) return existing.promise as Promise;\n\n if (this.#pending.size === 0) {\n this.#settled = new Promise((resolve) => {\n this.#resolveSettled = resolve;\n });\n }\n\n const promise = task.run();\n // Suppress unhandled rejection for callers that ignore the return value.\n promise.catch(() => {});\n // Cleanup: update pending and resolve settled regardless of outcome.\n const cleanup = () => {\n this.#pending.delete(task.id);\n if (this.#pending.size === 0) {\n this.#resolveSettled?.();\n this.#resolveSettled = null;\n }\n };\n promise.then(cleanup, cleanup);\n\n this.#pending.set(task.id, { task: task as TaskLike, promise: promise as Promise });\n return promise;\n }\n\n /**\n * Registers a callback to fire when all currently in-flight tasks settle.\n * If the runner is already idle, the callback is never called. If abortAll()\n * is called before the batch settles, the callback is superseded and silently\n * dropped — no stale callbacks, no generation token required by the caller.\n */\n whenSettled(callback: () => void): void {\n if (this.#pending.size === 0) return;\n const captured = this.#settled;\n captured.then(\n () => {\n if (this.#settled !== captured) return;\n callback();\n },\n () => {}\n );\n }\n\n abortAll(): void {\n for (const { task } of this.#pending.values()) task.abort();\n this.#pending.clear();\n // Resolve the current settled promise so any .then() handlers are queued,\n // then replace the reference — whenSettled callbacks that captured the old\n // reference will see the identity mismatch and be dropped.\n this.#resolveSettled?.();\n this.#resolveSettled = null;\n this.#settled = Promise.resolve();\n }\n\n destroy(): void {\n this.#destroyed = true;\n this.abortAll();\n }\n}\n\n// =============================================================================\n// SerialRunner\n// =============================================================================\n\n/**\n * Runs tasks one at a time in submission order.\n *\n * Each schedule() call returns a Promise that resolves or rejects with the\n * task's result when it is eventually executed. Tasks wait in queue until the\n * prior task completes.\n *\n * Serialization is achieved by chaining each task's run() onto the tail of a\n * shared promise chain — no explicit queue or drain loop needed.\n *\n * abortAll() aborts all pending (not yet started) tasks and the currently\n * in-flight task. Pending tasks still run briefly but receive an aborted\n * signal and are expected to exit early.\n */\nexport class SerialRunner {\n #chain: Promise = Promise.resolve();\n readonly #pending = new Set>();\n #current: TaskLike | null = null;\n #destroyed = false;\n\n schedule(task: TaskLike): Promise {\n if (this.#destroyed) return Promise.resolve() as Promise;\n const t = task as TaskLike;\n this.#pending.add(t);\n\n const result = this.#chain\n .then(() => {\n this.#pending.delete(t);\n this.#current = t;\n return task.run();\n })\n .finally(() => {\n this.#current = null;\n });\n\n // Advance the chain regardless of whether this task succeeds or fails.\n this.#chain = result.then(\n () => {},\n () => {}\n );\n\n return result as Promise;\n }\n\n /**\n * A promise that resolves when all currently-scheduled tasks have settled.\n * Use the reference as a generation token: capture it after scheduling a\n * batch, then check identity in the resolution callback to detect whether\n * a subsequent abortAll() + new batch has superseded this one.\n */\n get settled(): Promise {\n return this.#chain as Promise;\n }\n\n /**\n * Registers a callback to fire when all currently-pending tasks settle.\n * If the runner is already idle (no pending or running tasks), the callback\n * is never called. If new tasks are scheduled before the current batch\n * settles, the callback is superseded and silently dropped — no stale\n * callbacks, no generation token required by the caller.\n */\n whenSettled(callback: () => void): void {\n if (this.#pending.size === 0 && this.#current === null) return;\n const currentChain = this.#chain;\n currentChain.then(\n () => {\n if (this.#chain !== currentChain) return;\n callback();\n },\n () => {}\n );\n }\n\n /** Aborts and clears queued tasks without touching the in-flight task. */\n abortPending(): void {\n for (const task of this.#pending) task.abort();\n this.#pending.clear();\n }\n\n abortAll(): void {\n this.abortPending();\n this.#current?.abort();\n }\n\n destroy(): void {\n this.#destroyed = true;\n this.abortAll();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgEA,IAAa,OAAb,MAAuF;CACrF;CACA;CACA,mBAA4B,IAAI,gBAAgB;CAChD;CAEA,UAAsB;CACtB,SAA6B,KAAA;CAC7B,SAA6B,KAAA;CAE7B,YAAY,OAAiD,QAAqB;EAChF,KAAKA,SAAS;EACd,MAAM,QAAQ,QAAQ;EACtB,KAAK,KAAK,OAAO,UAAU,aAAa,MAAM,IAAK,SAAS,WAAW;EACvE,KAAKE,UAAU,QAAQ,SACnB,eAAe,CAAC,KAAKD,iBAAiB,QAAQ,OAAO,MAAM,CAAC,IAC5D,KAAKA,iBAAiB;CAC5B;CAEA,IAAI,SAAqB;EACvB,OAAO,KAAKE;CACd;CAEA,IAAI,QAA0C;EAC5C,OAAO,KAAKC;CACd;CAEA,IAAI,QAA0C;EAC5C,OAAO,KAAKC;CACd;CAEA,MAAM,MAAuB;EAC3B,KAAKF,UAAU;EACf,IAAI;GACF,MAAM,SAAS,MAAM,KAAKH,OAAO,KAAKE,OAAO;GAC7C,KAAKE,SAAS;GACd,KAAKD,UAAU;GACf,OAAO;EACT,SAAS,GAAG;GACV,KAAKE,SAAS;GACd,KAAKF,UAAU;GACf,MAAM;EACR;CACF;CAEA,QAAc;EACZ,KAAKF,iBAAiB,MAAM;CAC9B;AACF;;;;;;;;AAaA,IAAa,mBAAb,MAA8B;CAC5B,2BAAoB,IAAI,IAA6E;CACrG,WAA0B,QAAQ,QAAQ;CAC1C,kBAAuC;CACvC,aAAa;CAEb,SAA0C,MAAiD;EACzF,IAAI,KAAKM,YAAY,OAAO,QAAQ,QAAQ;EAC5C,MAAM,WAAW,KAAKD,SAAS,IAAI,KAAK,EAAE;EAC1C,IAAI,UAAU,OAAO,SAAS;EAE9B,IAAI,KAAKA,SAAS,SAAS,GACzB,KAAKE,WAAW,IAAI,SAAS,YAAY;GACvC,KAAKC,kBAAkB;EACzB,CAAC;EAGH,MAAM,UAAU,KAAK,IAAI;EAEzB,QAAQ,YAAY,CAAC,CAAC;EAEtB,MAAM,gBAAgB;GACpB,KAAKH,SAAS,OAAO,KAAK,EAAE;GAC5B,IAAI,KAAKA,SAAS,SAAS,GAAG;IAC5B,KAAKG,kBAAkB;IACvB,KAAKA,kBAAkB;GACzB;EACF;EACA,QAAQ,KAAK,SAAS,OAAO;EAE7B,KAAKH,SAAS,IAAI,KAAK,IAAI;GAAQ;GAA6C;EAA4B,CAAC;EAC7G,OAAO;CACT;;;;;;;CAQA,YAAY,UAA4B;EACtC,IAAI,KAAKA,SAAS,SAAS,GAAG;EAC9B,MAAM,WAAW,KAAKE;EACtB,SAAS,WACD;GACJ,IAAI,KAAKA,aAAa,UAAU;GAChC,SAAS;EACX,SACM,CAAC,CACT;CACF;CAEA,WAAiB;EACf,KAAK,MAAM,EAAE,UAAU,KAAKF,SAAS,OAAO,GAAG,KAAK,MAAM;EAC1D,KAAKA,SAAS,MAAM;EAIpB,KAAKG,kBAAkB;EACvB,KAAKA,kBAAkB;EACvB,KAAKD,WAAW,QAAQ,QAAQ;CAClC;CAEA,UAAgB;EACd,KAAKD,aAAa;EAClB,KAAK,SAAS;CAChB;AACF;;;;;;;;;;;;;;;AAoBA,IAAa,eAAb,MAA0B;CACxB,SAA2B,QAAQ,QAAQ;CAC3C,2BAAoB,IAAI,IAAgC;CACxD,WAA8C;CAC9C,aAAa;CAEb,SAA0C,MAAiD;EACzF,IAAI,KAAKA,YAAY,OAAO,QAAQ,QAAQ;EAC5C,MAAM,IAAI;EACV,KAAKD,SAAS,IAAI,CAAC;EAEnB,MAAM,SAAS,KAAKI,OACjB,WAAW;GACV,KAAKJ,SAAS,OAAO,CAAC;GACtB,KAAKK,WAAW;GAChB,OAAO,KAAK,IAAI;EAClB,CAAC,CAAC,CACD,cAAc;GACb,KAAKA,WAAW;EAClB,CAAC;EAGH,KAAKD,SAAS,OAAO,WACb,CAAC,SACD,CAAC,CACT;EAEA,OAAO;CACT;;;;;;;CAQA,IAAI,UAAyB;EAC3B,OAAO,KAAKA;CACd;;;;;;;;CASA,YAAY,UAA4B;EACtC,IAAI,KAAKJ,SAAS,SAAS,KAAK,KAAKK,aAAa,MAAM;EACxD,MAAM,eAAe,KAAKD;EAC1B,aAAa,WACL;GACJ,IAAI,KAAKA,WAAW,cAAc;GAClC,SAAS;EACX,SACM,CAAC,CACT;CACF;;CAGA,eAAqB;EACnB,KAAK,MAAM,QAAQ,KAAKJ,UAAU,KAAK,MAAM;EAC7C,KAAKA,SAAS,MAAM;CACtB;CAEA,WAAiB;EACf,KAAK,aAAa;EAClB,KAAKK,UAAU,MAAM;CACvB;CAEA,UAAgB;EACd,KAAKJ,aAAa;EAClB,KAAK,SAAS;CAChB;AACF"} \ No newline at end of file diff --git a/dist/default/dom.js b/dist/default/dom.js new file mode 100644 index 00000000..de0cd6d2 --- /dev/null +++ b/dist/default/dom.js @@ -0,0 +1,9 @@ +import { appendSegment } from "./media/dom/mse/append-segment.js"; +import { flushBuffer } from "./media/dom/mse/buffer-flusher.js"; +import { destroyVttResolver, resolveVttSegment } from "./media/dom/text/resolve-vtt-segment.js"; +import { loadAudioSegments, loadVideoSegments } from "./playback/behaviors/dom/load-segments.js"; +import { setupTextTrackActors } from "./playback/behaviors/dom/setup-text-track-actors.js"; +import { trackCurrentTime } from "./playback/behaviors/dom/track-current-time.js"; +import { trackLoadTriggers } from "./playback/behaviors/dom/track-load-triggers.js"; +import { trackPlaybackRate } from "./playback/behaviors/dom/track-playback-rate.js"; +export { appendSegment, destroyVttResolver, flushBuffer, loadAudioSegments, loadVideoSegments, resolveVttSegment, setupTextTrackActors, trackCurrentTime, trackLoadTriggers, trackPlaybackRate }; diff --git a/dist/default/hls.js b/dist/default/hls.js new file mode 100644 index 00000000..bc975394 --- /dev/null +++ b/dist/default/hls.js @@ -0,0 +1,6 @@ +import { derivePerTypeStartMediaTime, deriveSharedMinStartMediaTime } from "./playback/behaviors/establish-start-media-time.js"; +import { createSimpleHlsEngine } from "./playback/engines/hls/engine.js"; +import { SimpleHlsMediaElement, SimpleHlsMediaMixin, simpleHlsMediaDefaultProps } from "./playback/engines/hls/adapter.js"; +import { createHlsAudioOnlyEngine } from "./playback/engines/hls/engine-audio-only.js"; +import { SimpleHlsAudioOnlyMediaElement, SimpleHlsAudioOnlyMediaMixin, simpleHlsAudioOnlyMediaDefaultProps } from "./playback/engines/hls/adapter-audio-only.js"; +export { SimpleHlsAudioOnlyMediaElement, SimpleHlsAudioOnlyMediaMixin, SimpleHlsMediaElement, SimpleHlsMediaMixin, createHlsAudioOnlyEngine, createSimpleHlsEngine, derivePerTypeStartMediaTime, deriveSharedMinStartMediaTime, simpleHlsAudioOnlyMediaDefaultProps, simpleHlsMediaDefaultProps }; diff --git a/dist/default/index.js b/dist/default/index.js new file mode 100644 index 00000000..13333928 --- /dev/null +++ b/dist/default/index.js @@ -0,0 +1,23 @@ +import { computed, signal, snapshot, untrack, update } from "./core/signals/primitives.js"; +import { createComposition, defineBehavior } from "./core/composition/create-composition.js"; +import { makeShareSignals } from "./core/composition/share-signals.js"; +import { effect } from "./core/signals/effect.js"; +import { ConcurrentRunner, SerialRunner, Task } from "./core/tasks/task.js"; +import { createMachineActor } from "./core/actors/create-machine-actor.js"; +import { createTransitionActor } from "./core/actors/create-transition-actor.js"; +import { createMachineReactor } from "./core/reactors/create-machine-reactor.js"; +//#region src/index.ts +/** +* Stream Processing Framework (SPF) for Video.js 10 +* +* The compositional primitives: createComposition, signals, tasks, actors, +* reactors. Media-domain helpers and the HLS playback engine live behind +* the `./dom` and `./hls` subpaths. +* +* @packageDocumentation +*/ +const VERSION = "0.1.0"; +//#endregion +export { ConcurrentRunner, SerialRunner, Task, VERSION, computed, createComposition, createMachineActor, createMachineReactor, createTransitionActor, defineBehavior, effect, makeShareSignals, signal, snapshot, untrack, update }; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/default/index.js.map b/dist/default/index.js.map new file mode 100644 index 00000000..dd715e8e --- /dev/null +++ b/dist/default/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":["/**\n * Stream Processing Framework (SPF) for Video.js 10\n *\n * The compositional primitives: createComposition, signals, tasks, actors,\n * reactors. Media-domain helpers and the HLS playback engine live behind\n * the `./dom` and `./hls` subpaths.\n *\n * @packageDocumentation\n */\n\nexport const VERSION = '0.1.0';\n\n// =============================================================================\n// Composition\n// =============================================================================\n\nexport type {\n Behavior,\n BehaviorCleanup,\n BehaviorDeps,\n Composition,\n CompositionOptions,\n ContextSignals,\n InferBehaviorConfig,\n InferBehaviorContext,\n InferBehaviorState,\n ResolveBehaviorConfig,\n ResolveBehaviorContext,\n ResolveBehaviorState,\n StateSignals,\n} from './core/composition/create-composition';\nexport { createComposition, defineBehavior } from './core/composition/create-composition';\nexport type { ShareSignalsConfig } from './core/composition/share-signals';\nexport { makeShareSignals } from './core/composition/share-signals';\n\n// =============================================================================\n// Signals\n// =============================================================================\n\nexport { effect } from './core/signals/effect';\nexport type { Computed, ReadonlySignal, Signal, SignalOptions } from './core/signals/primitives';\nexport { computed, signal, snapshot, untrack, update } from './core/signals/primitives';\n\n// =============================================================================\n// Tasks\n// =============================================================================\n\nexport type { TaskConfig, TaskLike, TaskStatus } from './core/tasks/task';\nexport { ConcurrentRunner, SerialRunner, Task } from './core/tasks/task';\n\n// =============================================================================\n// Machine (shared by actors and reactors)\n// =============================================================================\n\nexport type { Machine, MachineSnapshot } from './core/machine';\n\n// =============================================================================\n// Actors\n// =============================================================================\n\nexport type { ActorSnapshot, CallbackActor, SignalActor } from './core/actors/actor';\nexport type {\n ActorDefinition,\n ActorStateDefinition,\n HandlerContext,\n MessageActor,\n RunnerLike,\n} from './core/actors/create-machine-actor';\nexport { createMachineActor } from './core/actors/create-machine-actor';\nexport type { TransitionActor } from './core/actors/create-transition-actor';\nexport { createTransitionActor } from './core/actors/create-transition-actor';\n\n// =============================================================================\n// Reactors\n// =============================================================================\n\nexport type {\n Reactor,\n ReactorDefinition,\n ReactorDeriveFn,\n ReactorEffectFn,\n ReactorStateDefinition,\n} from './core/reactors/create-machine-reactor';\nexport { createMachineReactor } from './core/reactors/create-machine-reactor';\n"],"mappings":";;;;;;;;;;;;;;;;;;AAUA,MAAa,UAAU"} \ No newline at end of file diff --git a/dist/default/media-tracks.js b/dist/default/media-tracks.js new file mode 100644 index 00000000..efb496ff --- /dev/null +++ b/dist/default/media-tracks.js @@ -0,0 +1,2 @@ +import { dedupedAudioTracks, dedupedVideoTracks, findAudioTrackById, findVideoTrackById, frameRateToNumber, isSameAudioTrack, isSameVideoTrack, toUserAudioTrackSelection, toUserVideoTrackSelection } from "./media/media-tracks/media-tracks.js"; +export { dedupedAudioTracks, dedupedVideoTracks, findAudioTrackById, findVideoTrackById, frameRateToNumber, isSameAudioTrack, isSameVideoTrack, toUserAudioTrackSelection, toUserVideoTrackSelection }; diff --git a/dist/default/media/abr/quality-selection.js b/dist/default/media/abr/quality-selection.js new file mode 100644 index 00000000..826bc2cf --- /dev/null +++ b/dist/default/media/abr/quality-selection.js @@ -0,0 +1,21 @@ +//#region src/media/abr/quality-selection.ts +/** +* Default quality selection configuration. +* Values match Shaka Player upgrade threshold (0.85 = 15% headroom). +*/ +const DEFAULT_QUALITY_CONFIG = { + safetyMargin: .85, + upgradeMargin: 1.15 +}; +/** +* Resolution as a total pixel count (`width × height`), the basis for +* comparing two tracks at the same bitrate. Missing dimensions count as 0, so +* tracks without resolution metadata (e.g. audio) area-compare equal. +*/ +function resolutionArea(track) { + return (track.width ?? 0) * (track.height ?? 0); +} +//#endregion +export { DEFAULT_QUALITY_CONFIG, resolutionArea }; + +//# sourceMappingURL=quality-selection.js.map \ No newline at end of file diff --git a/dist/default/media/abr/quality-selection.js.map b/dist/default/media/abr/quality-selection.js.map new file mode 100644 index 00000000..0200f13b --- /dev/null +++ b/dist/default/media/abr/quality-selection.js.map @@ -0,0 +1 @@ +{"version":3,"file":"quality-selection.js","names":[],"sources":["../../../../src/media/abr/quality-selection.ts"],"sourcesContent":["/**\n * Quality Selection Algorithm\n *\n * Selects optimal video track based on bandwidth estimate with safety margin.\n * Stateless selection - picks highest quality that fits bandwidth.\n *\n * Key concepts:\n * - **Safety margin** (0.85): Pick track where bandwidth >= track.bandwidth / 0.85\n * - This ensures 15% headroom to avoid buffering\n * - At same bandwidth, prefer higher resolution\n */\n\nimport type { PartiallyResolvedVideoTrack, VideoTrack } from '../types';\n\n/**\n * Quality selection configuration.\n */\nexport interface QualityConfig {\n /**\n * Safety margin (0-1).\n * To select a track, need: currentBandwidth >= track.bandwidth / safetyMargin.\n * Default 0.85 means track must use ≤85% of available bandwidth (15% headroom).\n */\n safetyMargin: number;\n /**\n * Upgrade hysteresis ratio (>= 1). When `currentTrack` is supplied, an\n * upgrade is applied only if `optimal.bandwidth >= currentTrack.bandwidth * upgradeMargin`.\n * Downgrades are always applied. Default 1.15 means optimal must clear\n * the current bandwidth by at least 15% to trigger an upgrade.\n */\n upgradeMargin: number;\n}\n\n/**\n * Default quality selection configuration.\n * Values match Shaka Player upgrade threshold (0.85 = 15% headroom).\n */\nexport const DEFAULT_QUALITY_CONFIG: QualityConfig = {\n safetyMargin: 0.85,\n upgradeMargin: 1.15,\n};\n\n/**\n * Selection context for `selectQuality`. The `bandwidth` field carries the\n * current network estimate; `safetyMargin` / `upgradeMargin` override the\n * defaults; `currentTrack` enables upgrade-vs-downgrade hysteresis.\n *\n * Shape matches the unified `selectOptimal` contract of\n * `setupTrackSwitching` (`playback/behaviors/track-switching.ts`) so the\n * function can be passed directly as a variant's `selectOptimal`.\n */\nexport interface SelectQualityCtx\n extends Partial {\n bandwidth: number;\n /**\n * Track currently selected. When supplied, `selectQuality` returns\n * `currentTrack` (no change) for upgrades that don't clear the\n * `upgradeMargin`. When omitted, no hysteresis is applied — the\n * computed optimal is returned regardless.\n */\n currentTrack?: T;\n}\n\n/**\n * Select the track to apply now, given a context that carries current\n * bandwidth, an optional `currentTrack`, and tuning overrides. Returns:\n *\n * - The bandwidth-fitting optimal when no `currentTrack` is supplied.\n * - The optimal when it's a downgrade vs. `currentTrack` (downgrades\n * apply immediately — no hysteresis).\n * - The optimal when it clears `currentTrack.bandwidth * upgradeMargin`\n * (upgrade clears hysteresis).\n * - `currentTrack` itself when an upgrade doesn't clear the margin\n * (stay put — caller checks identity to no-op).\n *\n * \"Optimal\" is the highest-bandwidth track where the available bandwidth\n * meets the safety requirement (`bandwidth >= track.bandwidth / safetyMargin`).\n * Falls back to the lowest-bandwidth track when nothing fits the safety\n * margin (preserves a definitive pick under under-bandwidth conditions).\n *\n * @example\n * const tracks = [low, mid, high];\n * selectQuality(tracks, { bandwidth: 5_000_000, currentTrack: low });\n * // Returns `high` if 5 Mbps clears safety AND high.bandwidth >= low.bandwidth * upgradeMargin.\n * // Returns `low` (no-op signal) otherwise.\n */\nexport function selectQuality(\n tracks: readonly (PartiallyResolvedVideoTrack | VideoTrack)[],\n ctx: SelectQualityCtx\n): PartiallyResolvedVideoTrack | VideoTrack | undefined {\n if (tracks.length === 0) {\n return undefined;\n }\n\n const safetyMargin = ctx.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;\n const upgradeMargin = ctx.upgradeMargin ?? DEFAULT_QUALITY_CONFIG.upgradeMargin;\n const { bandwidth: currentBandwidth, currentTrack } = ctx;\n\n // Sort tracks by bandwidth (lowest first)\n const sortedTracks = tracks.slice().sort((a, b) => a.bandwidth - b.bandwidth);\n\n // Start with no selection\n let chosen: PartiallyResolvedVideoTrack | VideoTrack | undefined;\n\n for (const track of sortedTracks) {\n // Check if we have enough bandwidth for this track with safety margin\n // Required bandwidth = track.bandwidth / safetyMargin\n const requiredBandwidth = track.bandwidth / safetyMargin;\n\n if (currentBandwidth >= requiredBandwidth) {\n // We can support this track - prefer it if better than current choice\n if (\n !chosen ||\n track.bandwidth > chosen.bandwidth ||\n (track.bandwidth === chosen.bandwidth && hasHigherResolution(track, chosen))\n ) {\n chosen = track;\n }\n }\n }\n\n // If no track fits with safety margin, fall back to lowest quality\n const optimal = chosen ?? sortedTracks[0];\n if (!optimal) return undefined;\n\n // Apply upgrade hysteresis. No currentTrack → no hysteresis, return\n // optimal. Downgrade (optimal.bandwidth < current) → always apply.\n // Upgrade → only when `optimal.bandwidth >= current * upgradeMargin`,\n // else stay put (return currentTrack so caller's id-compare no-ops).\n if (!currentTrack) return optimal;\n if (optimal.bandwidth < currentTrack.bandwidth) return optimal;\n if (optimal.bandwidth >= currentTrack.bandwidth * upgradeMargin) return optimal;\n return currentTrack;\n}\n\n/**\n * Select the lowest-bandwidth track from the candidate set.\n *\n * Used as a safety-net fallback by callers that need a definitive pick when\n * a primary selection algorithm declines to choose. Pairs naturally with\n * `selectQuality` — when ABR has no good answer (e.g., all candidates\n * exceed available bandwidth and the algorithm doesn't fall back\n * internally), the lowest bitrate is the safest default.\n *\n * @param tracks - Candidate tracks (can be unsorted)\n * @returns The lowest-bandwidth track, or `undefined` if `tracks` is empty\n *\n * @example\n * const fallback = selectLowestQuality(tracks);\n */\nexport function selectLowestQuality(tracks: readonly T[]): T | undefined {\n if (tracks.length === 0) return undefined;\n return tracks.reduce((min, t) => (t.bandwidth < min.bandwidth ? t : min));\n}\n\n/**\n * Resolution as a total pixel count (`width × height`), the basis for\n * comparing two tracks at the same bitrate. Missing dimensions count as 0, so\n * tracks without resolution metadata (e.g. audio) area-compare equal.\n */\nexport function resolutionArea(track: { width?: number; height?: number }): number {\n return (track.width ?? 0) * (track.height ?? 0);\n}\n\n/**\n * Check if track A has higher resolution than track B.\n * Compares by total pixel count (width × height).\n *\n * @param trackA - First track to compare\n * @param trackB - Second track to compare\n * @returns True if trackA has more pixels than trackB\n */\nfunction hasHigherResolution(\n trackA: PartiallyResolvedVideoTrack | VideoTrack,\n trackB: PartiallyResolvedVideoTrack | VideoTrack\n): boolean {\n return resolutionArea(trackA) > resolutionArea(trackB);\n}\n"],"mappings":";;;;;AAqCA,MAAa,yBAAwC;CACnD,cAAc;CACd,eAAe;AACjB;;;;;;AAwHA,SAAgB,eAAe,OAAoD;CACjF,QAAQ,MAAM,SAAS,MAAM,MAAM,UAAU;AAC/C"} \ No newline at end of file diff --git a/dist/default/media/buffer/back-buffer.js b/dist/default/media/buffer/back-buffer.js new file mode 100644 index 00000000..9fdd84e7 --- /dev/null +++ b/dist/default/media/buffer/back-buffer.js @@ -0,0 +1,46 @@ +//#region src/media/buffer/back-buffer.ts +/** +* Default back buffer configuration. +*/ +const DEFAULT_BACK_BUFFER_CONFIG = { keepSegments: 2 }; +/** +* Calculate back buffer flush point. +* +* Determines where to flush old segments from the back buffer. +* Keeps a fixed number of segments behind the current playback position. +* +* Algorithm: +* 1. Find segments before currentTime +* 2. Count back N segments (keepSegments) +* 3. Return startTime of segment N+1 back (flush everything before this) +* +* @param segments - Available segments (should be sorted by startTime) +* @param currentTime - Current playback position in seconds +* @param config - Optional back buffer configuration +* @returns Time in seconds to flush up to (flush range: [0, flushEnd)) +* +* @example +* const segments = [ +* { startTime: 0, duration: 6, ... }, +* { startTime: 6, duration: 6, ... }, +* { startTime: 12, duration: 6, ... }, +* { startTime: 18, duration: 6, ... }, +* ]; +* +* // Playing at 18s, keep 2 segments +* const flushEnd = calculateBackBufferFlushPoint(segments, 18); +* // Returns 6 (flush [0, 6), keep [6-18)) +*/ +function calculateBackBufferFlushPoint(segments, currentTime, config = DEFAULT_BACK_BUFFER_CONFIG) { + if (segments.length === 0) return 0; + const segmentsBefore = segments.filter((seg) => seg.startTime < currentTime); + if (segmentsBefore.length === 0) return 0; + const segmentsToFlush = segmentsBefore.length - config.keepSegments; + if (segmentsToFlush <= 0) return 0; + if (segmentsToFlush >= segmentsBefore.length) return currentTime; + return segmentsBefore[segmentsToFlush].startTime; +} +//#endregion +export { DEFAULT_BACK_BUFFER_CONFIG, calculateBackBufferFlushPoint }; + +//# sourceMappingURL=back-buffer.js.map \ No newline at end of file diff --git a/dist/default/media/buffer/back-buffer.js.map b/dist/default/media/buffer/back-buffer.js.map new file mode 100644 index 00000000..f42cf0a2 --- /dev/null +++ b/dist/default/media/buffer/back-buffer.js.map @@ -0,0 +1 @@ +{"version":3,"file":"back-buffer.js","names":[],"sources":["../../../../src/media/buffer/back-buffer.ts"],"sourcesContent":["/**\n * Back Buffer Strategy (Simple)\n *\n * Calculates flush points for back buffer management.\n * V1 uses simple \"keep N segments\" strategy.\n */\n\nimport type { Segment } from '../types';\n\n/**\n * Back buffer configuration.\n */\nexport interface BackBufferConfig {\n /**\n * Number of segments to keep behind current playback position.\n * Default: 2 segments.\n */\n keepSegments: number;\n}\n\n/**\n * Default back buffer configuration.\n */\nexport const DEFAULT_BACK_BUFFER_CONFIG: BackBufferConfig = {\n keepSegments: 2,\n};\n\n/**\n * Calculate back buffer flush point.\n *\n * Determines where to flush old segments from the back buffer.\n * Keeps a fixed number of segments behind the current playback position.\n *\n * Algorithm:\n * 1. Find segments before currentTime\n * 2. Count back N segments (keepSegments)\n * 3. Return startTime of segment N+1 back (flush everything before this)\n *\n * @param segments - Available segments (should be sorted by startTime)\n * @param currentTime - Current playback position in seconds\n * @param config - Optional back buffer configuration\n * @returns Time in seconds to flush up to (flush range: [0, flushEnd))\n *\n * @example\n * const segments = [\n * { startTime: 0, duration: 6, ... },\n * { startTime: 6, duration: 6, ... },\n * { startTime: 12, duration: 6, ... },\n * { startTime: 18, duration: 6, ... },\n * ];\n *\n * // Playing at 18s, keep 2 segments\n * const flushEnd = calculateBackBufferFlushPoint(segments, 18);\n * // Returns 6 (flush [0, 6), keep [6-18))\n */\nexport function calculateBackBufferFlushPoint(\n segments: Segment[],\n currentTime: number,\n config: BackBufferConfig = DEFAULT_BACK_BUFFER_CONFIG\n): number {\n if (segments.length === 0) {\n return 0;\n }\n\n // Find all segments before current time (not including current segment)\n const segmentsBefore = segments.filter((seg) => seg.startTime < currentTime);\n\n // If no segments before current time, nothing to flush\n if (segmentsBefore.length === 0) {\n return 0;\n }\n\n // Calculate how many segments to flush\n // Keep last N segments, flush the rest\n const segmentsToFlush = segmentsBefore.length - config.keepSegments;\n\n // If we don't have enough segments to flush, keep everything\n if (segmentsToFlush <= 0) {\n return 0;\n }\n\n // If we want to flush all segments, return currentTime\n // (flush everything before current playback position)\n if (segmentsToFlush >= segmentsBefore.length) {\n return currentTime;\n }\n\n // Return the startTime of the first segment we want to keep\n // Everything before this will be flushed\n return segmentsBefore[segmentsToFlush]!.startTime;\n}\n"],"mappings":";;;;AAuBA,MAAa,6BAA+C,EAC1D,cAAc,EAChB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,8BACd,UACA,aACA,SAA2B,4BACnB;CACR,IAAI,SAAS,WAAW,GACtB,OAAO;CAIT,MAAM,iBAAiB,SAAS,QAAQ,QAAQ,IAAI,YAAY,WAAW;CAG3E,IAAI,eAAe,WAAW,GAC5B,OAAO;CAKT,MAAM,kBAAkB,eAAe,SAAS,OAAO;CAGvD,IAAI,mBAAmB,GACrB,OAAO;CAKT,IAAI,mBAAmB,eAAe,QACpC,OAAO;CAKT,OAAO,eAAe,gBAAgB,CAAE;AAC1C"} \ No newline at end of file diff --git a/dist/default/media/buffer/forward-buffer.js b/dist/default/media/buffer/forward-buffer.js new file mode 100644 index 00000000..9c556bd8 --- /dev/null +++ b/dist/default/media/buffer/forward-buffer.js @@ -0,0 +1,119 @@ +import { SEGMENT_TIME_EPSILON } from "../types/index.js"; +//#region src/media/buffer/forward-buffer.ts +/** +* Forward Buffer Strategy (Simple) +* +* Determines which segments to load for forward buffer management. +* V1 uses simple fixed-duration strategy (buffer N seconds ahead). +*/ +/** +* Merge intervals into sorted, disjoint ranges; touching or overlapping ranges +* (gap ≤ `epsilon`) are joined. Empty/inverted ranges are dropped. +*/ +function mergeTimeRanges(ranges, epsilon = SEGMENT_TIME_EPSILON) { + const sorted = ranges.filter((r) => r.end > r.start).sort((a, b) => a.start - b.start); + const merged = []; + for (const r of sorted) { + const last = merged[merged.length - 1]; + if (last && r.start <= last.end + epsilon) last.end = Math.max(last.end, r.end); + else merged.push({ + start: r.start, + end: r.end + }); + } + return merged; +} +/** +* Whether `[start, end)` is fully covered by the union of `merged` ranges, +* tolerating `epsilon` of overhang at each edge. `merged` must be disjoint and +* sorted (as returned by `mergeTimeRanges`), so full coverage means a single +* merged range contains the interval. +*/ +function isTimeRangeCovered(start, end, merged, epsilon = SEGMENT_TIME_EPSILON) { + return merged.some((r) => r.start <= start + epsilon && r.end >= end - epsilon); +} +/** +* Default forward buffer configuration. +*/ +const DEFAULT_FORWARD_BUFFER_CONFIG = { bufferDuration: 30 }; +/** +* Get segments that need to be loaded for forward buffer. +* +* Determines which segments to load to maintain target buffer duration. +* Handles discontiguous buffering (gaps after seeks). +* +* Algorithm: +* 1. Calculate target time: currentTime + bufferDuration +* 2. Find all segments in range [currentTime, targetTime) +* 3. Filter out segments already buffered at that time position +* 4. Return segments to load (fills gaps + extends to target) +* +* @param segments - All available segments from playlist +* @param bufferedSegments - Segments already buffered (ordered by startTime) +* @param currentTime - Current playback position in seconds +* @param config - Optional forward buffer configuration +* @returns Array of segments to load (empty if buffer is sufficient) +* +* @example +* // After seek: buffered [0-12, 18-30], playing at 7s +* const toLoad = getSegmentsToLoad(segments, buffered, 7, { bufferDuration: 24 }); +* // Returns [seg-12, seg-30] (fills gap, extends to target 31s) +*/ +/** +* Calculate the start time from which to flush forward buffer content. +* +* Content that starts at or beyond `currentTime + bufferDuration` is no +* longer needed for the current playback position and should be removed +* from the SourceBuffer. This prevents unbounded accumulation of scattered +* SourceBuffer content after seeks, which can cause QuotaExceededError on +* long-form content. +* +* Returns `Infinity` when nothing needs flushing (no buffered segments +* exist beyond the threshold). +* +* @param bufferedSegments - Segments currently tracked in the buffer model +* @param currentTime - Current playback position in seconds +* @param config - Optional forward buffer configuration +* @returns Start time to flush from (flush range: [flushStart, Infinity)), +* or Infinity if no flush is needed +* +* @example +* // Playing at 0s, buffered [0,6,12,18,24,30,36], bufferDuration=30 +* const flushStart = calculateForwardFlushPoint(segments, 0); +* // Returns 30 — flush [30, Infinity), keep [0, 30) +*/ +function calculateForwardFlushPoint(bufferedSegments, currentTime, config = DEFAULT_FORWARD_BUFFER_CONFIG) { + if (bufferedSegments.length === 0) return Infinity; + const threshold = currentTime + config.bufferDuration; + const beyond = bufferedSegments.filter((seg) => seg.startTime >= threshold); + if (beyond.length === 0) return Infinity; + return Math.min(...beyond.map((seg) => seg.startTime)); +} +/** +* Find the start time of the segment containing `currentTime` (or the last +* segment if `currentTime` is past the end). Returns `undefined` when +* `currentTime` is undefined or when no segment matches. +* +* Used to detect "meaningful currentTime change" — two times that map to the +* same segment start aren't a load-trigger; crossing a segment boundary is. +*/ +function segmentStartForTime(currentTime, segments) { + if (currentTime == null) return void 0; + return segments?.find(({ startTime, duration }, i, all) => currentTime >= startTime && (currentTime < startTime + duration || i === all.length - 1))?.startTime; +} +function getSegmentsToLoad(segments, bufferedSegments, currentTime, config = DEFAULT_FORWARD_BUFFER_CONFIG) { + if (segments.length === 0) return []; + const targetTime = currentTime + config.bufferDuration; + const bufferedStartTimes = new Set(bufferedSegments.map((seg) => seg.startTime)); + return segments.filter((seg, i) => { + const segmentEnd = seg.startTime + seg.duration; + const overlapsPlayhead = i === segments.length - 1 || segmentEnd > currentTime; + const isInRange = seg.startTime < targetTime && overlapsPlayhead; + const isNotBuffered = !bufferedStartTimes.has(seg.startTime); + return isInRange && isNotBuffered; + }); +} +//#endregion +export { DEFAULT_FORWARD_BUFFER_CONFIG, calculateForwardFlushPoint, getSegmentsToLoad, isTimeRangeCovered, mergeTimeRanges, segmentStartForTime }; + +//# sourceMappingURL=forward-buffer.js.map \ No newline at end of file diff --git a/dist/default/media/buffer/forward-buffer.js.map b/dist/default/media/buffer/forward-buffer.js.map new file mode 100644 index 00000000..98a2d19d --- /dev/null +++ b/dist/default/media/buffer/forward-buffer.js.map @@ -0,0 +1 @@ +{"version":3,"file":"forward-buffer.js","names":[],"sources":["../../../../src/media/buffer/forward-buffer.ts"],"sourcesContent":["/**\n * Forward Buffer Strategy (Simple)\n *\n * Determines which segments to load for forward buffer management.\n * V1 uses simple fixed-duration strategy (buffer N seconds ahead).\n */\n\nimport { SEGMENT_TIME_EPSILON, type Segment } from '../types';\n\n/** A half-open `[start, end)` interval on the presentation timeline. */\nexport interface TimeRange {\n start: number;\n end: number;\n}\n\n/**\n * Merge intervals into sorted, disjoint ranges; touching or overlapping ranges\n * (gap ≤ `epsilon`) are joined. Empty/inverted ranges are dropped.\n */\nexport function mergeTimeRanges(ranges: readonly TimeRange[], epsilon = SEGMENT_TIME_EPSILON): TimeRange[] {\n const sorted = ranges.filter((r) => r.end > r.start).sort((a, b) => a.start - b.start);\n const merged: TimeRange[] = [];\n for (const r of sorted) {\n const last = merged[merged.length - 1];\n if (last && r.start <= last.end + epsilon) {\n last.end = Math.max(last.end, r.end);\n } else {\n merged.push({ start: r.start, end: r.end });\n }\n }\n return merged;\n}\n\n/**\n * Whether `[start, end)` is fully covered by the union of `merged` ranges,\n * tolerating `epsilon` of overhang at each edge. `merged` must be disjoint and\n * sorted (as returned by `mergeTimeRanges`), so full coverage means a single\n * merged range contains the interval.\n */\nexport function isTimeRangeCovered(\n start: number,\n end: number,\n merged: readonly TimeRange[],\n epsilon = SEGMENT_TIME_EPSILON\n): boolean {\n return merged.some((r) => r.start <= start + epsilon && r.end >= end - epsilon);\n}\n\n/**\n * Forward buffer configuration.\n */\nexport interface ForwardBufferConfig {\n /**\n * Duration in seconds to buffer ahead of current playback position.\n * Default: 30 seconds.\n */\n bufferDuration: number;\n}\n\n/**\n * Default forward buffer configuration.\n */\nexport const DEFAULT_FORWARD_BUFFER_CONFIG: ForwardBufferConfig = {\n bufferDuration: 30,\n};\n\n/**\n * Get segments that need to be loaded for forward buffer.\n *\n * Determines which segments to load to maintain target buffer duration.\n * Handles discontiguous buffering (gaps after seeks).\n *\n * Algorithm:\n * 1. Calculate target time: currentTime + bufferDuration\n * 2. Find all segments in range [currentTime, targetTime)\n * 3. Filter out segments already buffered at that time position\n * 4. Return segments to load (fills gaps + extends to target)\n *\n * @param segments - All available segments from playlist\n * @param bufferedSegments - Segments already buffered (ordered by startTime)\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Array of segments to load (empty if buffer is sufficient)\n *\n * @example\n * // After seek: buffered [0-12, 18-30], playing at 7s\n * const toLoad = getSegmentsToLoad(segments, buffered, 7, { bufferDuration: 24 });\n * // Returns [seg-12, seg-30] (fills gap, extends to target 31s)\n */\n/**\n * Calculate the start time from which to flush forward buffer content.\n *\n * Content that starts at or beyond `currentTime + bufferDuration` is no\n * longer needed for the current playback position and should be removed\n * from the SourceBuffer. This prevents unbounded accumulation of scattered\n * SourceBuffer content after seeks, which can cause QuotaExceededError on\n * long-form content.\n *\n * Returns `Infinity` when nothing needs flushing (no buffered segments\n * exist beyond the threshold).\n *\n * @param bufferedSegments - Segments currently tracked in the buffer model\n * @param currentTime - Current playback position in seconds\n * @param config - Optional forward buffer configuration\n * @returns Start time to flush from (flush range: [flushStart, Infinity)),\n * or Infinity if no flush is needed\n *\n * @example\n * // Playing at 0s, buffered [0,6,12,18,24,30,36], bufferDuration=30\n * const flushStart = calculateForwardFlushPoint(segments, 0);\n * // Returns 30 — flush [30, Infinity), keep [0, 30)\n */\nexport function calculateForwardFlushPoint(\n bufferedSegments: readonly Segment[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): number {\n if (bufferedSegments.length === 0) return Infinity;\n\n const threshold = currentTime + config.bufferDuration;\n\n // Find segments that start at or beyond the threshold\n const beyond = bufferedSegments.filter((seg) => seg.startTime >= threshold);\n\n if (beyond.length === 0) return Infinity;\n\n // Flush from the earliest such segment onward\n return Math.min(...beyond.map((seg) => seg.startTime));\n}\n\n/**\n * Find the start time of the segment containing `currentTime` (or the last\n * segment if `currentTime` is past the end). Returns `undefined` when\n * `currentTime` is undefined or when no segment matches.\n *\n * Used to detect \"meaningful currentTime change\" — two times that map to the\n * same segment start aren't a load-trigger; crossing a segment boundary is.\n */\nexport function segmentStartForTime(\n currentTime: number | undefined,\n segments: readonly Pick[] | undefined\n): number | undefined {\n if (currentTime == null) return undefined;\n return segments?.find(\n ({ startTime, duration }, i, all) =>\n currentTime >= startTime && (currentTime < startTime + duration || i === all.length - 1)\n )?.startTime;\n}\n\nexport function getSegmentsToLoad(\n segments: readonly Segment[],\n bufferedSegments: readonly Pick[],\n currentTime: number,\n config: ForwardBufferConfig = DEFAULT_FORWARD_BUFFER_CONFIG\n): Segment[] {\n if (segments.length === 0) {\n return [];\n }\n\n // Calculate target buffer end time\n const targetTime = currentTime + config.bufferDuration;\n\n // Create set of buffered segment start times for fast lookup\n // V1 simple: if ANY segment is buffered at a given time, don't load for that time\n // V2 (future): would compare by startTime + bitrate/track for quality switching\n const bufferedStartTimes = new Set(bufferedSegments.map((seg) => seg.startTime));\n\n // Find segments to load:\n // - Overlaps buffer window [currentTime, targetTime)\n // - Not already buffered at that time position\n const toLoad = segments.filter((seg, i) => {\n const segmentEnd = seg.startTime + seg.duration;\n const isLast = i === segments.length - 1;\n // Interior segments use strict `>` so a segment the playhead just finished\n // (endTime === currentTime at a boundary) isn't reloaded. The terminal\n // segment has no successor and no reachable position past it — the playhead\n // is clamped to the presentation's range — so it needs no upper bound: it's\n // loadable whenever the forward window reaches it. That resolves the\n // exact-end seek (#1828) and the post-loop re-seek — where\n // `MediaSource.duration`, clamped by `endOfStream()` to the true buffered\n // end, has grown a hair past the model's EXTINF-derived end — with no\n // dependency on any duration value.\n //\n // LIVE: for an un-ended live/DVR presentation `isLast` is the sliding edge\n // segment; loading it eagerly here is benign today (forward-window-bounded,\n // deduped by the buffered check), but the live effort should confirm the\n // edge / end-of-stream interaction when it lands.\n const overlapsPlayhead = isLast || segmentEnd > currentTime;\n const isInRange = seg.startTime < targetTime && overlapsPlayhead;\n\n // Must not have a segment buffered at this time position\n const isNotBuffered = !bufferedStartTimes.has(seg.startTime);\n\n return isInRange && isNotBuffered;\n });\n\n return toLoad;\n}\n"],"mappings":";;;;;;;;;;;;AAmBA,SAAgB,gBAAgB,QAA8B,UAAU,sBAAmC;CACzG,MAAM,SAAS,OAAO,QAAQ,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;CACrF,MAAM,SAAsB,CAAC;CAC7B,KAAK,MAAM,KAAK,QAAQ;EACtB,MAAM,OAAO,OAAO,OAAO,SAAS;EACpC,IAAI,QAAQ,EAAE,SAAS,KAAK,MAAM,SAChC,KAAK,MAAM,KAAK,IAAI,KAAK,KAAK,EAAE,GAAG;OAEnC,OAAO,KAAK;GAAE,OAAO,EAAE;GAAO,KAAK,EAAE;EAAI,CAAC;CAE9C;CACA,OAAO;AACT;;;;;;;AAQA,SAAgB,mBACd,OACA,KACA,QACA,UAAU,sBACD;CACT,OAAO,OAAO,MAAM,MAAM,EAAE,SAAS,QAAQ,WAAW,EAAE,OAAO,MAAM,OAAO;AAChF;;;;AAgBA,MAAa,gCAAqD,EAChE,gBAAgB,GAClB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,SAAgB,2BACd,kBACA,aACA,SAA8B,+BACtB;CACR,IAAI,iBAAiB,WAAW,GAAG,OAAO;CAE1C,MAAM,YAAY,cAAc,OAAO;CAGvC,MAAM,SAAS,iBAAiB,QAAQ,QAAQ,IAAI,aAAa,SAAS;CAE1E,IAAI,OAAO,WAAW,GAAG,OAAO;CAGhC,OAAO,KAAK,IAAI,GAAG,OAAO,KAAK,QAAQ,IAAI,SAAS,CAAC;AACvD;;;;;;;;;AAUA,SAAgB,oBACd,aACA,UACoB;CACpB,IAAI,eAAe,MAAM,OAAO,KAAA;CAChC,OAAO,UAAU,MACd,EAAE,WAAW,YAAY,GAAG,QAC3B,eAAe,cAAc,cAAc,YAAY,YAAY,MAAM,IAAI,SAAS,EAC1F,CAAC,EAAE;AACL;AAEA,SAAgB,kBACd,UACA,kBACA,aACA,SAA8B,+BACnB;CACX,IAAI,SAAS,WAAW,GACtB,OAAO,CAAC;CAIV,MAAM,aAAa,cAAc,OAAO;CAKxC,MAAM,qBAAqB,IAAI,IAAI,iBAAiB,KAAK,QAAQ,IAAI,SAAS,CAAC;CA+B/E,OA1Be,SAAS,QAAQ,KAAK,MAAM;EACzC,MAAM,aAAa,IAAI,YAAY,IAAI;EAgBvC,MAAM,mBAfS,MAAM,SAAS,SAAS,KAeJ,aAAa;EAChD,MAAM,YAAY,IAAI,YAAY,cAAc;EAGhD,MAAM,gBAAgB,CAAC,mBAAmB,IAAI,IAAI,SAAS;EAE3D,OAAO,aAAa;CACtB,CAEY;AACd"} \ No newline at end of file diff --git a/dist/default/media/dom/capabilities.js b/dist/default/media/dom/capabilities.js new file mode 100644 index 00000000..104128fe --- /dev/null +++ b/dist/default/media/dom/capabilities.js @@ -0,0 +1,70 @@ +import { NON_FMP4_CONTAINER_MIMES } from "../hls/parse-media-playlist.js"; +import { buildMimeCodec, isCodecSupported } from "./mse/mediasource-setup.js"; +//#region src/media/dom/capabilities.ts +/** +* Capability probing — the engine's foundation for asking the browser what it +* can actually decode before committing a rendition to the pipeline. +* +* Today this is the synchronous codec half: `canPlayTrack` answers "can this +* environment play this track?" by building the track's MIME codec string and +* passing it to `MediaSource.isTypeSupported` (via `isCodecSupported`). It's the +* DOM implementation of the DOM-free `CanPlayTrack` predicate the +* track-switching hard-constraint pre-pass consumes — injected through engine +* config so the (DOM-free) behavior never imports a DOM API directly. +* +* Results are memoized by built MIME string: codec support is a pure function +* of (codec, environment) and never changes after load, so probing is lazy +* (per candidate, at constraint-apply time) but each unique MIME is asked once. +* +* Future cluster-D phases (async `requestMediaKeySystemAccess` key-system +* probing, `SourceBuffer.changeType()` availability) extend this surface; the +* async ones land as a state-slot writer behavior rather than a config +* predicate, since their verdict resolves asynchronously. +*/ +const codecSupportCache = /* @__PURE__ */ new Map(); +/** +* Whether the environment can decode `track`, by codec. Builds the track's +* MIME codec string and checks `MediaSource.isTypeSupported`, memoized by MIME. +* A track without enough to probe — no `mimeType`, or no declared `codecs` +* (CODECS is optional per the HLS spec) — is unprobeable and passes through as +* playable (`true`) rather than being dropped; the late `createSourceBuffer` +* check stays as the backstop for those. +* +* Detected non-fMP4 containers (`video/mp2t`, `audio/aac`) are asserted +* unsupported regardless of the probe, so they're pruned before selection +* (the type makes no pick) instead of failing/stalling deep in the pipeline. +* Two different reasons, neither UA-based: +* +* - **MPEG-TS** can't be played at all here: `isTypeSupported('video/mp2t…')` is +* a genuine false positive on Chromium (reports `true` but appends produce no +* buffered range), and this engine has no TS transmux pipeline. +* - **Raw ADTS AAC** is a *temporary* limitation. The browser genuinely +* supports it (Chrome/Safari decode `audio/aac`; Firefox doesn't), so it could +* be made playable — but our segment actors / loading behaviors / append +* pipeline assume every rendition has an `EXT-X-MAP` init segment (e.g. an +* `append-init` task with an empty URL, fMP4-shaped append handling). Until +* that init-segment assumption is removed, ADTS would fetch but never buffer +* (a silent stall), so we assert it unplayable for now. FOLLOW-UP: drop the +* init-required assumption in the pipeline and switch this to a bare-MIME +* probe (`buildMimeCodec` would project `audio/aac` with no codecs) so it +* plays where the browser supports it. +* +* Override via the engine's `canPlayTrack` config when those pipelines land. +*/ +const canPlayTrack = (track) => { + if (track.mimeType && NON_FMP4_CONTAINER_MIMES.has(track.mimeType)) return false; + if (!track.mimeType || !track.codecs?.length) return true; + const mimeCodec = buildMimeCodec({ + mimeType: track.mimeType, + codecs: track.codecs + }); + const cached = codecSupportCache.get(mimeCodec); + if (cached !== void 0) return cached; + const supported = isCodecSupported(mimeCodec); + codecSupportCache.set(mimeCodec, supported); + return supported; +}; +//#endregion +export { canPlayTrack }; + +//# sourceMappingURL=capabilities.js.map \ No newline at end of file diff --git a/dist/default/media/dom/capabilities.js.map b/dist/default/media/dom/capabilities.js.map new file mode 100644 index 00000000..d3a86f8d --- /dev/null +++ b/dist/default/media/dom/capabilities.js.map @@ -0,0 +1 @@ +{"version":3,"file":"capabilities.js","names":[],"sources":["../../../../src/media/dom/capabilities.ts"],"sourcesContent":["/**\n * Capability probing — the engine's foundation for asking the browser what it\n * can actually decode before committing a rendition to the pipeline.\n *\n * Today this is the synchronous codec half: `canPlayTrack` answers \"can this\n * environment play this track?\" by building the track's MIME codec string and\n * passing it to `MediaSource.isTypeSupported` (via `isCodecSupported`). It's the\n * DOM implementation of the DOM-free `CanPlayTrack` predicate the\n * track-switching hard-constraint pre-pass consumes — injected through engine\n * config so the (DOM-free) behavior never imports a DOM API directly.\n *\n * Results are memoized by built MIME string: codec support is a pure function\n * of (codec, environment) and never changes after load, so probing is lazy\n * (per candidate, at constraint-apply time) but each unique MIME is asked once.\n *\n * Future cluster-D phases (async `requestMediaKeySystemAccess` key-system\n * probing, `SourceBuffer.changeType()` availability) extend this surface; the\n * async ones land as a state-slot writer behavior rather than a config\n * predicate, since their verdict resolves asynchronously.\n */\n\nimport { NON_FMP4_CONTAINER_MIMES } from '../hls/parse-media-playlist';\nimport type { CanPlayTrack } from '../types';\nimport { buildMimeCodec, isCodecSupported } from './mse/mediasource-setup';\n\nconst codecSupportCache = new Map();\n\n/**\n * Whether the environment can decode `track`, by codec. Builds the track's\n * MIME codec string and checks `MediaSource.isTypeSupported`, memoized by MIME.\n * A track without enough to probe — no `mimeType`, or no declared `codecs`\n * (CODECS is optional per the HLS spec) — is unprobeable and passes through as\n * playable (`true`) rather than being dropped; the late `createSourceBuffer`\n * check stays as the backstop for those.\n *\n * Detected non-fMP4 containers (`video/mp2t`, `audio/aac`) are asserted\n * unsupported regardless of the probe, so they're pruned before selection\n * (the type makes no pick) instead of failing/stalling deep in the pipeline.\n * Two different reasons, neither UA-based:\n *\n * - **MPEG-TS** can't be played at all here: `isTypeSupported('video/mp2t…')` is\n * a genuine false positive on Chromium (reports `true` but appends produce no\n * buffered range), and this engine has no TS transmux pipeline.\n * - **Raw ADTS AAC** is a *temporary* limitation. The browser genuinely\n * supports it (Chrome/Safari decode `audio/aac`; Firefox doesn't), so it could\n * be made playable — but our segment actors / loading behaviors / append\n * pipeline assume every rendition has an `EXT-X-MAP` init segment (e.g. an\n * `append-init` task with an empty URL, fMP4-shaped append handling). Until\n * that init-segment assumption is removed, ADTS would fetch but never buffer\n * (a silent stall), so we assert it unplayable for now. FOLLOW-UP: drop the\n * init-required assumption in the pipeline and switch this to a bare-MIME\n * probe (`buildMimeCodec` would project `audio/aac` with no codecs) so it\n * plays where the browser supports it.\n *\n * Override via the engine's `canPlayTrack` config when those pipelines land.\n */\nexport const canPlayTrack: CanPlayTrack = (track) => {\n if (track.mimeType && NON_FMP4_CONTAINER_MIMES.has(track.mimeType)) return false;\n if (!track.mimeType || !track.codecs?.length) return true;\n const mimeCodec = buildMimeCodec({ mimeType: track.mimeType, codecs: track.codecs });\n const cached = codecSupportCache.get(mimeCodec);\n if (cached !== undefined) return cached;\n const supported = isCodecSupported(mimeCodec);\n codecSupportCache.set(mimeCodec, supported);\n return supported;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAyBA,MAAM,oCAAoB,IAAI,IAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BnD,MAAa,gBAA8B,UAAU;CACnD,IAAI,MAAM,YAAY,yBAAyB,IAAI,MAAM,QAAQ,GAAG,OAAO;CAC3E,IAAI,CAAC,MAAM,YAAY,CAAC,MAAM,QAAQ,QAAQ,OAAO;CACrD,MAAM,YAAY,eAAe;EAAE,UAAU,MAAM;EAAU,QAAQ,MAAM;CAAO,CAAC;CACnF,MAAM,SAAS,kBAAkB,IAAI,SAAS;CAC9C,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,MAAM,YAAY,iBAAiB,SAAS;CAC5C,kBAAkB,IAAI,WAAW,SAAS;CAC1C,OAAO;AACT"} \ No newline at end of file diff --git a/dist/default/media/dom/mse/append-segment.js b/dist/default/media/dom/mse/append-segment.js new file mode 100644 index 00000000..751b5792 --- /dev/null +++ b/dist/default/media/dom/mse/append-segment.js @@ -0,0 +1,60 @@ +//#region src/media/dom/mse/append-segment.ts +/** +* Append media data to a SourceBuffer. +* +* Accepts either a full ArrayBuffer (single append) or an AsyncIterable of +* Uint8Array chunks (one append per chunk, in order). Waits for `updateend` +* between each call so appends are serialized correctly. +* +* Errors from the SourceBuffer (`error` event) or from the iterable are +* propagated as rejections. +*/ +async function appendSegment(sourceBuffer, data, signal) { + if (data instanceof ArrayBuffer) await appendChunk(sourceBuffer, data); + else try { + for await (const chunk of data) { + if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError"); + await appendChunk(sourceBuffer, chunk); + } + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError" && !sourceBuffer.updating) try { + sourceBuffer.abort(); + } catch {} + throw e; + } +} +async function appendChunk(sourceBuffer, data) { + if (sourceBuffer.updating) await new Promise((resolve) => { + const onUpdateEnd = () => { + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + resolve(); + }; + sourceBuffer.addEventListener("updateend", onUpdateEnd); + }); + return new Promise((resolve, reject) => { + const onUpdateEnd = () => { + cleanup(); + resolve(); + }; + const onError = (event) => { + cleanup(); + reject(/* @__PURE__ */ new Error(`SourceBuffer append error: ${event.type}`)); + }; + const cleanup = () => { + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + sourceBuffer.removeEventListener("error", onError); + }; + sourceBuffer.addEventListener("updateend", onUpdateEnd); + sourceBuffer.addEventListener("error", onError); + try { + sourceBuffer.appendBuffer(data); + } catch (error) { + cleanup(); + reject(error); + } + }); +} +//#endregion +export { appendSegment }; + +//# sourceMappingURL=append-segment.js.map \ No newline at end of file diff --git a/dist/default/media/dom/mse/append-segment.js.map b/dist/default/media/dom/mse/append-segment.js.map new file mode 100644 index 00000000..2d25aec8 --- /dev/null +++ b/dist/default/media/dom/mse/append-segment.js.map @@ -0,0 +1 @@ +{"version":3,"file":"append-segment.js","names":[],"sources":["../../../../../src/media/dom/mse/append-segment.ts"],"sourcesContent":["/**\n * Segment appender helper.\n *\n * Appends media data (ArrayBuffer or AsyncIterable stream) to a\n * SourceBuffer, waiting for `updateend` between calls so the browser can\n * process each append before the next one arrives.\n */\n\nimport type { SegmentData } from '../../types';\n\n/** Data accepted by appendSegment — the MSE-boundary alias of {@link SegmentData}. */\nexport type AppendData = SegmentData;\n\n/**\n * Append media data to a SourceBuffer.\n *\n * Accepts either a full ArrayBuffer (single append) or an AsyncIterable of\n * Uint8Array chunks (one append per chunk, in order). Waits for `updateend`\n * between each call so appends are serialized correctly.\n *\n * Errors from the SourceBuffer (`error` event) or from the iterable are\n * propagated as rejections.\n */\nexport async function appendSegment(sourceBuffer: SourceBuffer, data: AppendData, signal?: AbortSignal): Promise {\n if (data instanceof ArrayBuffer) {\n await appendChunk(sourceBuffer, data);\n } else {\n try {\n for await (const chunk of data) {\n // Check between chunks so an abort can stop streaming before the next\n // appendBuffer call. The current chunk (if any) has already landed in the\n // SourceBuffer; the partial: true flag in the actor model reflects this.\n if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');\n await appendChunk(sourceBuffer, chunk);\n }\n } catch (e) {\n // Reset the MSE segment parser on any abort to discard partial fMP4 box\n // data from the SourceBuffer's internal byte buffer. Two paths reach here:\n //\n // 1. Explicit abort check above (signal.aborted between chunks) — the\n // last appended chunk may have left the parser mid-fragment.\n //\n // 2. The fetch was cancelled (signal fired), causing the underlying\n // ReadableStream to error and the for-await loop to throw directly\n // without ever passing through the signal check above. Same cleanup\n // is needed: partial box data must be cleared before the next append.\n //\n // Without this, the next appendBuffer call sees stale partial data and\n // Chrome throws CHUNK_DEMUXER_ERROR_APPEND_FAILED.\n if (e instanceof DOMException && e.name === 'AbortError' && !sourceBuffer.updating) {\n try {\n sourceBuffer.abort();\n } catch {\n // Thrown if the MediaSource is not \"open\" (e.g. during teardown).\n }\n }\n throw e;\n }\n }\n}\n\nasync function appendChunk(sourceBuffer: SourceBuffer, data: ArrayBuffer | Uint8Array): Promise {\n if (sourceBuffer.updating) {\n await new Promise((resolve) => {\n const onUpdateEnd = () => {\n sourceBuffer.removeEventListener('updateend', onUpdateEnd);\n resolve();\n };\n sourceBuffer.addEventListener('updateend', onUpdateEnd);\n });\n }\n\n return new Promise((resolve, reject) => {\n const onUpdateEnd = () => {\n cleanup();\n resolve();\n };\n\n const onError = (event: Event) => {\n cleanup();\n reject(new Error(`SourceBuffer append error: ${event.type}`));\n };\n\n const cleanup = () => {\n sourceBuffer.removeEventListener('updateend', onUpdateEnd);\n sourceBuffer.removeEventListener('error', onError);\n };\n\n sourceBuffer.addEventListener('updateend', onUpdateEnd);\n sourceBuffer.addEventListener('error', onError);\n\n try {\n sourceBuffer.appendBuffer(data as ArrayBuffer);\n } catch (error) {\n cleanup();\n reject(error);\n }\n });\n}\n"],"mappings":";;;;;;;;;;;AAuBA,eAAsB,cAAc,cAA4B,MAAkB,QAAqC;CACrH,IAAI,gBAAgB,aAClB,MAAM,YAAY,cAAc,IAAI;MAEpC,IAAI;EACF,WAAW,MAAM,SAAS,MAAM;GAI9B,IAAI,QAAQ,SAAS,MAAM,OAAO,UAAU,IAAI,aAAa,WAAW,YAAY;GACpF,MAAM,YAAY,cAAc,KAAK;EACvC;CACF,SAAS,GAAG;EAcV,IAAI,aAAa,gBAAgB,EAAE,SAAS,gBAAgB,CAAC,aAAa,UACxE,IAAI;GACF,aAAa,MAAM;EACrB,QAAQ,CAER;EAEF,MAAM;CACR;AAEJ;AAEA,eAAe,YAAY,cAA4B,MAAgE;CACrH,IAAI,aAAa,UACf,MAAM,IAAI,SAAe,YAAY;EACnC,MAAM,oBAAoB;GACxB,aAAa,oBAAoB,aAAa,WAAW;GACzD,QAAQ;EACV;EACA,aAAa,iBAAiB,aAAa,WAAW;CACxD,CAAC;CAGH,OAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,oBAAoB;GACxB,QAAQ;GACR,QAAQ;EACV;EAEA,MAAM,WAAW,UAAiB;GAChC,QAAQ;GACR,uBAAO,IAAI,MAAM,8BAA8B,MAAM,MAAM,CAAC;EAC9D;EAEA,MAAM,gBAAgB;GACpB,aAAa,oBAAoB,aAAa,WAAW;GACzD,aAAa,oBAAoB,SAAS,OAAO;EACnD;EAEA,aAAa,iBAAiB,aAAa,WAAW;EACtD,aAAa,iBAAiB,SAAS,OAAO;EAE9C,IAAI;GACF,aAAa,aAAa,IAAmB;EAC/C,SAAS,OAAO;GACd,QAAQ;GACR,OAAO,KAAK;EACd;CACF,CAAC;AACH"} \ No newline at end of file diff --git a/dist/default/media/dom/mse/buffer-flusher.js b/dist/default/media/dom/mse/buffer-flusher.js new file mode 100644 index 00000000..0a06e7c6 --- /dev/null +++ b/dist/default/media/dom/mse/buffer-flusher.js @@ -0,0 +1,55 @@ +//#region src/media/dom/mse/buffer-flusher.ts +/** +* Buffer flusher helper (P12) +* +* Removes a time range from a SourceBuffer to manage memory. +*/ +/** +* Remove a time range from a SourceBuffer. +* +* Waits for the SourceBuffer to be ready (not updating), then removes +* the specified range. Returns a promise that resolves when removal completes. +* +* @param sourceBuffer - The SourceBuffer to remove data from +* @param start - Start of the time range to remove (seconds) +* @param end - End of the time range to remove (seconds) +* @returns Promise that resolves when removal completes +* +* @example +* await flushBuffer(videoSourceBuffer, 0, 30); +*/ +async function flushBuffer(sourceBuffer, start, end) { + if (sourceBuffer.updating) await new Promise((resolve) => { + const onUpdateEnd = () => { + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + resolve(); + }; + sourceBuffer.addEventListener("updateend", onUpdateEnd); + }); + return new Promise((resolve, reject) => { + const onUpdateEnd = () => { + cleanup(); + resolve(); + }; + const onError = (event) => { + cleanup(); + reject(/* @__PURE__ */ new Error(`SourceBuffer remove error: ${event.type}`)); + }; + const cleanup = () => { + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + sourceBuffer.removeEventListener("error", onError); + }; + sourceBuffer.addEventListener("updateend", onUpdateEnd); + sourceBuffer.addEventListener("error", onError); + try { + sourceBuffer.remove(start, end); + } catch (error) { + cleanup(); + reject(error); + } + }); +} +//#endregion +export { flushBuffer }; + +//# sourceMappingURL=buffer-flusher.js.map \ No newline at end of file diff --git a/dist/default/media/dom/mse/buffer-flusher.js.map b/dist/default/media/dom/mse/buffer-flusher.js.map new file mode 100644 index 00000000..87e041f2 --- /dev/null +++ b/dist/default/media/dom/mse/buffer-flusher.js.map @@ -0,0 +1 @@ +{"version":3,"file":"buffer-flusher.js","names":[],"sources":["../../../../../src/media/dom/mse/buffer-flusher.ts"],"sourcesContent":["/**\n * Buffer flusher helper (P12)\n *\n * Removes a time range from a SourceBuffer to manage memory.\n */\n\n/**\n * Remove a time range from a SourceBuffer.\n *\n * Waits for the SourceBuffer to be ready (not updating), then removes\n * the specified range. Returns a promise that resolves when removal completes.\n *\n * @param sourceBuffer - The SourceBuffer to remove data from\n * @param start - Start of the time range to remove (seconds)\n * @param end - End of the time range to remove (seconds)\n * @returns Promise that resolves when removal completes\n *\n * @example\n * await flushBuffer(videoSourceBuffer, 0, 30);\n */\nexport async function flushBuffer(sourceBuffer: SourceBuffer, start: number, end: number): Promise {\n // Wait for SourceBuffer to be ready (not currently updating)\n if (sourceBuffer.updating) {\n await new Promise((resolve) => {\n const onUpdateEnd = () => {\n sourceBuffer.removeEventListener('updateend', onUpdateEnd);\n resolve();\n };\n sourceBuffer.addEventListener('updateend', onUpdateEnd);\n });\n }\n\n return new Promise((resolve, reject) => {\n const onUpdateEnd = () => {\n cleanup();\n resolve();\n };\n\n const onError = (event: Event) => {\n cleanup();\n reject(new Error(`SourceBuffer remove error: ${event.type}`));\n };\n\n const cleanup = () => {\n sourceBuffer.removeEventListener('updateend', onUpdateEnd);\n sourceBuffer.removeEventListener('error', onError);\n };\n\n sourceBuffer.addEventListener('updateend', onUpdateEnd);\n sourceBuffer.addEventListener('error', onError);\n\n try {\n sourceBuffer.remove(start, end);\n } catch (error) {\n cleanup();\n reject(error);\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAoBA,eAAsB,YAAY,cAA4B,OAAe,KAA4B;CAEvG,IAAI,aAAa,UACf,MAAM,IAAI,SAAe,YAAY;EACnC,MAAM,oBAAoB;GACxB,aAAa,oBAAoB,aAAa,WAAW;GACzD,QAAQ;EACV;EACA,aAAa,iBAAiB,aAAa,WAAW;CACxD,CAAC;CAGH,OAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,oBAAoB;GACxB,QAAQ;GACR,QAAQ;EACV;EAEA,MAAM,WAAW,UAAiB;GAChC,QAAQ;GACR,uBAAO,IAAI,MAAM,8BAA8B,MAAM,MAAM,CAAC;EAC9D;EAEA,MAAM,gBAAgB;GACpB,aAAa,oBAAoB,aAAa,WAAW;GACzD,aAAa,oBAAoB,SAAS,OAAO;EACnD;EAEA,aAAa,iBAAiB,aAAa,WAAW;EACtD,aAAa,iBAAiB,SAAS,OAAO;EAE9C,IAAI;GACF,aAAa,OAAO,OAAO,GAAG;EAChC,SAAS,OAAO;GACd,QAAQ;GACR,OAAO,KAAK;EACd;CACF,CAAC;AACH"} \ No newline at end of file diff --git a/dist/default/media/dom/mse/duration.js b/dist/default/media/dom/mse/duration.js new file mode 100644 index 00000000..4a9c2c1f --- /dev/null +++ b/dist/default/media/dom/mse/duration.js @@ -0,0 +1,95 @@ +import { hasPresentationDuration } from "../../types/index.js"; +//#region src/media/dom/mse/duration.ts +/** +* Check if we have the basics to update MediaSource duration: +* a `mediaSource` and a `presentation` with a numeric duration. +*/ +function canUpdateDuration(presentation, mediaSource) { + return !!(mediaSource && presentation && hasPresentationDuration(presentation)); +} +function getBufferedEnd(buffers, isEndMatch) { + return [...buffers].reduce((endMatch, buffer) => { + const { buffered } = buffer; + if (!buffered.length) return endMatch; + const end = buffered.end(buffered.length - 1); + if (!endMatch) return end; + return isEndMatch(end, endMatch) ? end : endMatch; + }, void 0) ?? 0; +} +const isGreaterThan = (x, y) => x > y; +const isLessThan = (x, y) => x < y; +/** +* Get the maximum buffered end time across an iterable of SourceBuffers +* (typically `mediaSource.sourceBuffers`). Returns `0` when the collection is +* empty or no buffer has any buffered ranges. +*/ +function getMaxBufferedEnd(buffers) { + return getBufferedEnd(buffers, isGreaterThan); +} +/** +* Get the reachable buffered end across an iterable of SourceBuffers (typically +* `mediaSource.sourceBuffers`): the `min` of each buffer's last buffered-range end +* — the furthest point every track can play to (the intersection end). Buffers with +* no buffered ranges are skipped. Returns `0` when the collection is empty or no +* buffer has any buffered ranges. +* +* Counterpart to {@link getMaxBufferedEnd}: `max` bounds the overall presentation +* end (e.g. for setting `duration`), `min` bounds where playback can actually reach +* when tracks end at slightly different times (e.g. skewed A/V near end-of-stream). +*/ +function getMinBufferedEnd(buffers) { + return getBufferedEnd(buffers, isLessThan); +} +/** +* Check if the preconditions are met to *attempt* a `mediaSource.duration` +* write: a `mediaSource` is in scope and the presentation has a valid +* positive duration (or `Infinity` for live). +* +* Does **not** check `mediaSource.readyState` or `mediaSource.duration` — +* those are DOM properties the caller resolves at write time (e.g., by +* `await`ing `waitForMediaSourceOpen` and re-checking `readyState` after, +* and guarding on the existing `mediaSource.duration` for idempotency). +* Keeping these off the signal-driven predicate lets callers use this +* inside reactor state derivation without smuggling non-reactive DOM +* reads into `computed(...)`. +* +* `Infinity` is allowed — per the MSE spec, `mediaSource.duration = +Infinity` +* is how live playback signals an indefinite duration. +*/ +function shouldUpdateDuration(presentation, mediaSource) { + if (!canUpdateDuration(presentation, mediaSource)) return false; + const duration = presentation.duration; + if (Number.isNaN(duration) || duration <= 0) return false; + return true; +} +/** +* Wait for all currently-updating SourceBuffers in `buffers` to finish, or +* until `signal` aborts — whichever fires first. +* +* The MSE spec forbids setting `MediaSource.duration` while any attached +* SourceBuffer has `updating === true`. This defers until all are idle. +* Listeners are registered with `{ signal }` so an abort tears them down +* up-front rather than leaving them dangling until the next `updateend`. +*/ +function waitForSourceBuffersReady(buffers, signal) { + if (signal.aborted) return Promise.resolve(); + const updating = []; + for (const buf of buffers) if (buf.updating) updating.push(buf); + if (updating.length === 0) return Promise.resolve(); + return new Promise((resolve) => { + let remaining = updating.length; + const onUpdateEnd = () => { + remaining--; + if (remaining === 0) resolve(); + }; + for (const buf of updating) buf.addEventListener("updateend", onUpdateEnd, { + once: true, + signal + }); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} +//#endregion +export { canUpdateDuration, getBufferedEnd, getMaxBufferedEnd, getMinBufferedEnd, shouldUpdateDuration, waitForSourceBuffersReady }; + +//# sourceMappingURL=duration.js.map \ No newline at end of file diff --git a/dist/default/media/dom/mse/duration.js.map b/dist/default/media/dom/mse/duration.js.map new file mode 100644 index 00000000..d4872450 --- /dev/null +++ b/dist/default/media/dom/mse/duration.js.map @@ -0,0 +1 @@ +{"version":3,"file":"duration.js","names":[],"sources":["../../../../../src/media/dom/mse/duration.ts"],"sourcesContent":["/**\n * MediaSource duration helpers.\n *\n * Predicates and async primitives for propagating a presentation's duration\n * to `mediaSource.duration` under the MSE spec's constraints:\n *\n * - `duration` cannot be set while any attached SourceBuffer is `updating`.\n * - `duration` cannot be less than any buffered range's end time.\n *\n * The buffer-set helpers take a `SourceBufferList` (or any iterable of\n * `SourceBuffer`) — they operate uniformly across whatever buffers are\n * attached, so callers in audio-only, video-only, and mixed configurations\n * compose them without per-type plumbing. `mediaSource.sourceBuffers` is the\n * canonical aggregate.\n *\n * Consumed by `updateMediaSourceDuration` (DOM behavior) — kept here so the layering\n * stays clean: the predicates and wait helper are pure DOM/MSE primitives\n * with no `core/` reactivity.\n */\n\nimport type { MaybeResolvedPresentation } from '../../types';\nimport { hasPresentationDuration } from '../../types';\n\ntype SourceBufferIterable = SourceBufferList | Iterable;\n\n/**\n * Check if we have the basics to update MediaSource duration:\n * a `mediaSource` and a `presentation` with a numeric duration.\n */\nexport function canUpdateDuration(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined\n): boolean {\n return !!(mediaSource && presentation && hasPresentationDuration(presentation));\n}\n\nexport function getBufferedEnd(\n buffers: SourceBufferIterable,\n isEndMatch: (next: number, current: number) => boolean\n): number {\n return (\n ([...buffers].reduce((endMatch: number | undefined, buffer) => {\n const { buffered } = buffer;\n if (!buffered.length) return endMatch;\n const end = buffered.end(buffered.length - 1);\n if (!endMatch) return end;\n return isEndMatch(end, endMatch) ? end : endMatch;\n }, undefined) as number) ?? 0\n );\n}\n\nconst isGreaterThan = (x: number, y: number) => x > y;\nconst isLessThan = (x: number, y: number) => x < y;\n\n/**\n * Get the maximum buffered end time across an iterable of SourceBuffers\n * (typically `mediaSource.sourceBuffers`). Returns `0` when the collection is\n * empty or no buffer has any buffered ranges.\n */\nexport function getMaxBufferedEnd(buffers: SourceBufferIterable): number {\n return getBufferedEnd(buffers, isGreaterThan);\n}\n\n/**\n * Get the reachable buffered end across an iterable of SourceBuffers (typically\n * `mediaSource.sourceBuffers`): the `min` of each buffer's last buffered-range end\n * — the furthest point every track can play to (the intersection end). Buffers with\n * no buffered ranges are skipped. Returns `0` when the collection is empty or no\n * buffer has any buffered ranges.\n *\n * Counterpart to {@link getMaxBufferedEnd}: `max` bounds the overall presentation\n * end (e.g. for setting `duration`), `min` bounds where playback can actually reach\n * when tracks end at slightly different times (e.g. skewed A/V near end-of-stream).\n */\nexport function getMinBufferedEnd(buffers: SourceBufferIterable): number {\n return getBufferedEnd(buffers, isLessThan);\n}\n\n/**\n * Check if the preconditions are met to *attempt* a `mediaSource.duration`\n * write: a `mediaSource` is in scope and the presentation has a valid\n * positive duration (or `Infinity` for live).\n *\n * Does **not** check `mediaSource.readyState` or `mediaSource.duration` —\n * those are DOM properties the caller resolves at write time (e.g., by\n * `await`ing `waitForMediaSourceOpen` and re-checking `readyState` after,\n * and guarding on the existing `mediaSource.duration` for idempotency).\n * Keeping these off the signal-driven predicate lets callers use this\n * inside reactor state derivation without smuggling non-reactive DOM\n * reads into `computed(...)`.\n *\n * `Infinity` is allowed — per the MSE spec, `mediaSource.duration = +Infinity`\n * is how live playback signals an indefinite duration.\n */\nexport function shouldUpdateDuration(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined\n): boolean {\n if (!canUpdateDuration(presentation, mediaSource)) return false;\n\n const duration = presentation!.duration!;\n\n if (Number.isNaN(duration) || duration <= 0) return false;\n\n return true;\n}\n\n/**\n * Wait for all currently-updating SourceBuffers in `buffers` to finish, or\n * until `signal` aborts — whichever fires first.\n *\n * The MSE spec forbids setting `MediaSource.duration` while any attached\n * SourceBuffer has `updating === true`. This defers until all are idle.\n * Listeners are registered with `{ signal }` so an abort tears them down\n * up-front rather than leaving them dangling until the next `updateend`.\n */\nexport function waitForSourceBuffersReady(buffers: SourceBufferIterable, signal: AbortSignal): Promise {\n if (signal.aborted) return Promise.resolve();\n\n const updating: SourceBuffer[] = [];\n for (const buf of buffers) {\n if (buf.updating) updating.push(buf);\n }\n\n if (updating.length === 0) return Promise.resolve();\n\n return new Promise((resolve) => {\n let remaining = updating.length;\n const onUpdateEnd = () => {\n remaining--;\n if (remaining === 0) resolve();\n };\n\n for (const buf of updating) {\n buf.addEventListener('updateend', onUpdateEnd, { once: true, signal });\n }\n\n signal.addEventListener('abort', () => resolve(), { once: true });\n });\n}\n"],"mappings":";;;;;;AA6BA,SAAgB,kBACd,cACA,aACS;CACT,OAAO,CAAC,EAAE,eAAe,gBAAgB,wBAAwB,YAAY;AAC/E;AAEA,SAAgB,eACd,SACA,YACQ;CACR,OACG,CAAC,GAAG,OAAO,CAAC,CAAC,QAAQ,UAA8B,WAAW;EAC7D,MAAM,EAAE,aAAa;EACrB,IAAI,CAAC,SAAS,QAAQ,OAAO;EAC7B,MAAM,MAAM,SAAS,IAAI,SAAS,SAAS,CAAC;EAC5C,IAAI,CAAC,UAAU,OAAO;EACtB,OAAO,WAAW,KAAK,QAAQ,IAAI,MAAM;CAC3C,GAAG,KAAA,CAAS,KAAgB;AAEhC;AAEA,MAAM,iBAAiB,GAAW,MAAc,IAAI;AACpD,MAAM,cAAc,GAAW,MAAc,IAAI;;;;;;AAOjD,SAAgB,kBAAkB,SAAuC;CACvE,OAAO,eAAe,SAAS,aAAa;AAC9C;;;;;;;;;;;;AAaA,SAAgB,kBAAkB,SAAuC;CACvE,OAAO,eAAe,SAAS,UAAU;AAC3C;;;;;;;;;;;;;;;;;AAkBA,SAAgB,qBACd,cACA,aACS;CACT,IAAI,CAAC,kBAAkB,cAAc,WAAW,GAAG,OAAO;CAE1D,MAAM,WAAW,aAAc;CAE/B,IAAI,OAAO,MAAM,QAAQ,KAAK,YAAY,GAAG,OAAO;CAEpD,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,0BAA0B,SAA+B,QAAoC;CAC3G,IAAI,OAAO,SAAS,OAAO,QAAQ,QAAQ;CAE3C,MAAM,WAA2B,CAAC;CAClC,KAAK,MAAM,OAAO,SAChB,IAAI,IAAI,UAAU,SAAS,KAAK,GAAG;CAGrC,IAAI,SAAS,WAAW,GAAG,OAAO,QAAQ,QAAQ;CAElD,OAAO,IAAI,SAAe,YAAY;EACpC,IAAI,YAAY,SAAS;EACzB,MAAM,oBAAoB;GACxB;GACA,IAAI,cAAc,GAAG,QAAQ;EAC/B;EAEA,KAAK,MAAM,OAAO,UAChB,IAAI,iBAAiB,aAAa,aAAa;GAAE,MAAM;GAAM;EAAO,CAAC;EAGvE,OAAO,iBAAiB,eAAe,QAAQ,GAAG,EAAE,MAAM,KAAK,CAAC;CAClE,CAAC;AACH"} \ No newline at end of file diff --git a/dist/default/media/dom/mse/end-of-stream.js b/dist/default/media/dom/mse/end-of-stream.js new file mode 100644 index 00000000..ad3abfc0 --- /dev/null +++ b/dist/default/media/dom/mse/end-of-stream.js @@ -0,0 +1,20 @@ +//#region src/media/dom/mse/end-of-stream.ts +/** +* Check if the temporally last segment of `expectedSegments` is present in +* `appendedSegments` and not marked partial. +* +* Compares by segment ID rather than by a pipeline flag, so the result +* stays correct across quality switches (different tracks have different +* segment IDs) and back-buffer flushes (flushed segment IDs are removed +* from the appended list). +*/ +function isLastSegmentAppended(expectedSegments, appendedSegments) { + if (expectedSegments.length === 0) return true; + const lastSeg = expectedSegments[expectedSegments.length - 1]; + if (!lastSeg) return false; + return appendedSegments?.some((s) => s.id === lastSeg.id && !s.partial) ?? false; +} +//#endregion +export { isLastSegmentAppended }; + +//# sourceMappingURL=end-of-stream.js.map \ No newline at end of file diff --git a/dist/default/media/dom/mse/end-of-stream.js.map b/dist/default/media/dom/mse/end-of-stream.js.map new file mode 100644 index 00000000..fbb03f05 --- /dev/null +++ b/dist/default/media/dom/mse/end-of-stream.js.map @@ -0,0 +1 @@ +{"version":3,"file":"end-of-stream.js","names":[],"sources":["../../../../../src/media/dom/mse/end-of-stream.ts"],"sourcesContent":["/**\n * End-of-stream detection helpers.\n *\n * Pure predicate that compares a track's expected segment list against the\n * appended-segment list from an MSE SourceBufferActor's snapshot. Consumed\n * by the `endOfStream` playback behavior to decide when to call\n * `MediaSource.endOfStream()`.\n *\n * Kept here so the layering stays clean: predicate has no `core/`\n * reactivity and operates on plain segment lists extracted by the caller.\n */\n\n/**\n * Minimum information about an appended segment needed to decide whether\n * it counts toward end-of-stream readiness.\n *\n * Matches the shape of `SourceBufferActor`'s context segments — callers\n * typically pass `actor.snapshot.get().context.segments` directly.\n */\nexport interface AppendedSegment {\n id: string;\n /**\n * True while a streaming append is in progress for this segment. A\n * partial segment is still streaming — it does not count as the last\n * segment being ready.\n */\n partial?: boolean;\n}\n\n/**\n * Check if the temporally last segment of `expectedSegments` is present in\n * `appendedSegments` and not marked partial.\n *\n * Compares by segment ID rather than by a pipeline flag, so the result\n * stays correct across quality switches (different tracks have different\n * segment IDs) and back-buffer flushes (flushed segment IDs are removed\n * from the appended list).\n */\nexport function isLastSegmentAppended(\n expectedSegments: readonly { id: string }[],\n appendedSegments: readonly AppendedSegment[] | undefined\n): boolean {\n if (expectedSegments.length === 0) return true;\n const lastSeg = expectedSegments[expectedSegments.length - 1];\n if (!lastSeg) return false;\n return appendedSegments?.some((s) => s.id === lastSeg.id && !s.partial) ?? false;\n}\n"],"mappings":";;;;;;;;;;AAsCA,SAAgB,sBACd,kBACA,kBACS;CACT,IAAI,iBAAiB,WAAW,GAAG,OAAO;CAC1C,MAAM,UAAU,iBAAiB,iBAAiB,SAAS;CAC3D,IAAI,CAAC,SAAS,OAAO;CACrB,OAAO,kBAAkB,MAAM,MAAM,EAAE,OAAO,QAAQ,MAAM,CAAC,EAAE,OAAO,KAAK;AAC7E"} \ No newline at end of file diff --git a/dist/default/media/dom/mse/mediasource-setup.js b/dist/default/media/dom/mse/mediasource-setup.js new file mode 100644 index 00000000..385b489b --- /dev/null +++ b/dist/default/media/dom/mse/mediasource-setup.js @@ -0,0 +1,242 @@ +//#region src/media/dom/mse/mediasource-setup.ts +/** +* MediaSource Setup +* +* Utilities for creating and configuring MediaSource/ManagedMediaSource +* for MSE (Media Source Extensions) playback. +* +* Global ManagedMediaSource types are defined in ./mediasource.d.ts +*/ +/** +* Check if MediaSource API is supported. +*/ +function supportsMediaSource() { + return typeof MediaSource !== "undefined"; +} +/** +* Check if ManagedMediaSource API is supported. +* ManagedMediaSource is a newer Safari API with better lifecycle management. +*/ +function supportsManagedMediaSource() { + return typeof ManagedMediaSource !== "undefined"; +} +/** +* Create a MediaSource or ManagedMediaSource instance. +* +* @param options - Creation options +* @returns A MediaSource or ManagedMediaSource instance +* @throws Error if no MediaSource API is available +* +* @example +* const mediaSource = createMediaSource(); +* const mediaElement = document.querySelector('video'); +* attachMediaSource(mediaSource, mediaElement); +*/ +function createMediaSource(options = {}) { + const { preferManaged = false } = options; + if (preferManaged && supportsManagedMediaSource()) return new ManagedMediaSource(); + if (supportsMediaSource()) return new MediaSource(); + throw new Error("MediaSource API is not supported"); +} +/** +* Attach a MediaSource to an HTMLMediaElement via the `src` attribute. +* +* The object URL on the `src` attribute is the industry-hardened MSE attach +* across the browser matrix, and works for ManagedMediaSource too (`srcObject` +* buys nothing over it and forfeits the uniform URL lifecycle). +* +* @param mediaSource - The MediaSource to attach +* @param mediaElement - The media element to attach to +* @returns Object with URL and detach function +* +* @example +* const mediaSource = createMediaSource(); +* const { detach } = attachMediaSource(mediaSource, videoElement); +* // Use mediaSource... +* // Later, to clean up: +* detach(); +*/ +function attachMediaSource(mediaSource, mediaElement) { + if (supportsManagedMediaSource() && mediaSource instanceof ManagedMediaSource) mediaElement.disableRemotePlayback = true; + const url = URL.createObjectURL(mediaSource); + mediaElement.src = url; + const detach = ({ deferReset } = {}) => { + mediaElement.removeAttribute("src"); + scheduleReset(mediaSource, mediaElement, url, deferReset); + URL.revokeObjectURL(url); + }; + return { + url, + detach + }; +} +/** +* Attach a MediaSource as a `` child element. +* +* The object URL rides a `` inserted as the +* element's FIRST child (any bare `src` attribute is dropped; `load()` +* re-runs resource selection). Unlike `srcObject`/`src` — which commit the +* element to the MSE resource and ignore every `` child — this +* keeps sibling `` alternatives part of resource selection, so a +* composition can offer the element a natively-playable alternative next to +* MSE. Canonical consumer: `setupAirPlay`'s native-HLS fallback source, +* wired through `setupMediaSource`'s `attachMediaSource` config +* (https://webkit.org/blog/15036/how-to-use-media-source-extensions-with-airplay/). +*/ +function attachMediaSourceAsSourceElement(mediaSource, mediaElement) { + if (supportsManagedMediaSource() && mediaSource instanceof ManagedMediaSource) mediaElement.disableRemotePlayback = true; + const url = URL.createObjectURL(mediaSource); + const sourceEl = document.createElement("source"); + sourceEl.type = "video/mp4"; + sourceEl.src = url; + mediaElement.removeAttribute("src"); + mediaElement.prepend(sourceEl); + mediaElement.load(); + const detach = ({ deferReset } = {}) => { + sourceEl.remove(); + scheduleReset(mediaSource, mediaElement, url, deferReset); + URL.revokeObjectURL(url); + }; + return { + url, + detach + }; +} +/** +* Detach's `load()` reset, applied only when tearing down an **unclosed** +* attachment the element is still committed to: +* +* - **Closed MediaSource**: skip. The attachment is already dead, and the +* element deliberately keeps whatever playback state it carries for +* whatever attaches next — that attach's own `load()` performs the reset. +* - **Element moved to another resource**: skip — resetting would rip that +* resource out from under its owner. +*/ +function resetIfOwnedAndNotClosed(mediaSource, mediaElement, url) { + if (mediaSource.readyState !== "closed" && mediaElement.currentSrc === url) mediaElement.load(); +} +/** +* Run the reset now, or on the next microtask when the caller has sibling +* `` owners that clear on an effect (see {@link DetachOptions.deferReset}). +* +* Deferring is safe because removing this attachment's own source does not by +* itself re-run resource selection: the element stays committed to the object +* URL until something calls `load()`, so nothing starts playing in the gap. +*/ +function scheduleReset(mediaSource, mediaElement, url, deferReset) { + if (deferReset) { + queueMicrotask(() => resetIfOwnedAndNotClosed(mediaSource, mediaElement, url)); + return; + } + resetIfOwnedAndNotClosed(mediaSource, mediaElement, url); +} +/** +* Create a SourceBuffer on a MediaSource. +* +* @param mediaSource - The MediaSource (must be in 'open' state) +* @param mimeCodec - MIME type with codecs (e.g., 'video/mp4; codecs="avc1.42E01E"') +* @returns The created SourceBuffer +* @throws Error if MediaSource is not open or codec is unsupported +* +* @example +* const buffer = createSourceBuffer(mediaSource, 'video/mp4; codecs="avc1.42E01E"'); +*/ +function createSourceBuffer(mediaSource, mimeCodec) { + if (mediaSource.readyState !== "open") throw new Error("MediaSource is not open"); + if (!isCodecSupported(mimeCodec)) throw new Error(`Codec not supported: ${mimeCodec}`); + return mediaSource.addSourceBuffer(mimeCodec); +} +/** +* Build a MIME codec string from a track's `mimeType` + `codecs`. Works +* on partially-resolved tracks — both fields come from the multivariant +* playlist and are available before media-playlist resolution. +* +* @param track - Track carrying `mimeType` and `codecs` +* @returns MIME codec string suitable for `MediaSource.addSourceBuffer` +* +* @example +* buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] }) +* // => 'video/mp4; codecs="avc1.42E01E"' +*/ +function buildMimeCodec(track) { + const codecString = track.codecs?.join(",") ?? ""; + return `${track.mimeType}; codecs="${codecString}"`; +} +/** +* Check if a codec is supported. +* +* @param mimeCodec - MIME type with codecs string +* @returns True if the codec is supported +* +* @example +* if (isCodecSupported('video/mp4; codecs="avc1.42E01E"')) { +* // Create source buffer +* } +*/ +function isCodecSupported(mimeCodec) { + if (!supportsMediaSource()) return false; + return MediaSource.isTypeSupported(mimeCodec); +} +/** +* Observe `mediaSource.readyState` changes via DOM events. +* +* Listens to `sourceopen`, `sourceended`, and `sourceclose` and invokes +* `onChange` with the current `readyState` after each event. Listeners +* are automatically removed when `abortSignal` is aborted. +* +* @param mediaSource - The MediaSource to observe +* @param abortSignal - AbortSignal that controls listener lifetime +* @param onChange - Called with the current readyState after each change +* +* @example +* const controller = new AbortController(); +* onMediaSourceReadyStateChange(mediaSource, controller.signal, (state) => { +* if (state === 'open') { ... } +* }); +* // Later: controller.abort(); +*/ +function onMediaSourceReadyStateChange(mediaSource, abortSignal, onChange) { + const update = () => onChange(mediaSource.readyState); + const options = { signal: abortSignal }; + mediaSource.addEventListener("sourceopen", update, options); + mediaSource.addEventListener("sourceended", update, options); + mediaSource.addEventListener("sourceclose", update, options); +} +/** +* Wait until `mediaSource.readyState` transitions away from `'closed'` +* (the next `sourceopen`/`sourceended`/`sourceclose` event), or until +* `signal` aborts — whichever fires first. +* +* Resolves immediately when `readyState` is already `'open'` or `'ended'` +* (no further transition is coming, so there's nothing to wait for). The +* caller is expected to re-check `readyState` after the await to +* distinguish `'open'` from terminal states. +* +* Companion to `onMediaSourceReadyStateChange` for one-shot use in async +* sequences (e.g., a behavior's reactor entry that needs to wait for the +* MediaSource to attach before performing a spec-conforming mutation). +* +* @example +* await waitForMediaSourceOpen(mediaSource, signal); +* if (signal.aborted || mediaSource.readyState !== 'open') return; +* // safe to perform 'open'-state-only work +*/ +function waitForMediaSourceOpen(mediaSource, signal) { + if (signal.aborted) return Promise.resolve(); + if (mediaSource.readyState !== "closed") return Promise.resolve(); + return new Promise((resolve) => { + const done = () => resolve(); + const options = { + once: true, + signal + }; + mediaSource.addEventListener("sourceopen", done, options); + mediaSource.addEventListener("sourceended", done, options); + mediaSource.addEventListener("sourceclose", done, options); + signal.addEventListener("abort", done, { once: true }); + }); +} +//#endregion +export { attachMediaSource, attachMediaSourceAsSourceElement, buildMimeCodec, createMediaSource, createSourceBuffer, isCodecSupported, onMediaSourceReadyStateChange, supportsManagedMediaSource, supportsMediaSource, waitForMediaSourceOpen }; + +//# sourceMappingURL=mediasource-setup.js.map \ No newline at end of file diff --git a/dist/default/media/dom/mse/mediasource-setup.js.map b/dist/default/media/dom/mse/mediasource-setup.js.map new file mode 100644 index 00000000..543520b2 --- /dev/null +++ b/dist/default/media/dom/mse/mediasource-setup.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mediasource-setup.js","names":[],"sources":["../../../../../src/media/dom/mse/mediasource-setup.ts"],"sourcesContent":["/**\n * MediaSource Setup\n *\n * Utilities for creating and configuring MediaSource/ManagedMediaSource\n * for MSE (Media Source Extensions) playback.\n *\n * Global ManagedMediaSource types are defined in ./mediasource.d.ts\n */\n\n/**\n * Check if MediaSource API is supported.\n */\nexport function supportsMediaSource(): boolean {\n return typeof MediaSource !== 'undefined';\n}\n\n/**\n * Check if ManagedMediaSource API is supported.\n * ManagedMediaSource is a newer Safari API with better lifecycle management.\n */\nexport function supportsManagedMediaSource(): boolean {\n return typeof ManagedMediaSource !== 'undefined';\n}\n\n/**\n * Options for creating a MediaSource.\n */\nexport interface CreateMediaSourceOptions {\n /** Prefer ManagedMediaSource when available (default: false for broader compatibility). */\n preferManaged?: boolean;\n}\n\n/**\n * Create a MediaSource or ManagedMediaSource instance.\n *\n * @param options - Creation options\n * @returns A MediaSource or ManagedMediaSource instance\n * @throws Error if no MediaSource API is available\n *\n * @example\n * const mediaSource = createMediaSource();\n * const mediaElement = document.querySelector('video');\n * attachMediaSource(mediaSource, mediaElement);\n */\nexport function createMediaSource(options: CreateMediaSourceOptions = {}): MediaSource {\n const { preferManaged = false } = options;\n\n if (preferManaged && supportsManagedMediaSource()) {\n return new ManagedMediaSource!();\n }\n\n if (supportsMediaSource()) {\n return new MediaSource();\n }\n\n throw new Error('MediaSource API is not supported');\n}\n\n/**\n * Options for `detach`.\n */\nexport interface DetachOptions {\n /**\n * Run the `load()` reset on the next microtask instead of synchronously.\n *\n * Required whenever another owner contributes sibling `` children to\n * the same element and drops them from a signal effect: effects re-run on a\n * microtask, so a synchronous reset would run resource selection while those\n * siblings are still in the DOM and commit the element to one of them.\n * Canonical caller: `setupMediaSource`, whose compositions may include\n * `setupAirPlay`'s native-HLS fallback source.\n *\n * The ownership guard is evaluated when the reset actually fires, so a\n * re-attach landing in the interim correctly suppresses it.\n */\n deferReset?: boolean;\n}\n\n/**\n * Result of attaching a MediaSource to a media element.\n */\nexport interface AttachMediaSourceResult {\n /** The object URL created for the MediaSource. */\n url: string;\n /** Detach the MediaSource and clean up resources. */\n detach: (options?: DetachOptions) => void;\n}\n\n/**\n * Attach a MediaSource to an HTMLMediaElement via the `src` attribute.\n *\n * The object URL on the `src` attribute is the industry-hardened MSE attach\n * across the browser matrix, and works for ManagedMediaSource too (`srcObject`\n * buys nothing over it and forfeits the uniform URL lifecycle).\n *\n * @param mediaSource - The MediaSource to attach\n * @param mediaElement - The media element to attach to\n * @returns Object with URL and detach function\n *\n * @example\n * const mediaSource = createMediaSource();\n * const { detach } = attachMediaSource(mediaSource, videoElement);\n * // Use mediaSource...\n * // Later, to clean up:\n * detach();\n */\nexport function attachMediaSource(mediaSource: MediaSource, mediaElement: HTMLMediaElement): AttachMediaSourceResult {\n // ManagedMediaSource requires disableRemotePlayback — without it Safari\n // will not fire sourceopen. (MMS-only: on other platforms the flag governs\n // the standard Remote Playback API and must be left alone.)\n if (supportsManagedMediaSource() && mediaSource instanceof ManagedMediaSource!) {\n mediaElement.disableRemotePlayback = true;\n }\n\n const url = URL.createObjectURL(mediaSource);\n mediaElement.src = url;\n\n const detach = ({ deferReset }: DetachOptions = {}): void => {\n mediaElement.removeAttribute('src');\n scheduleReset(mediaSource, mediaElement, url, deferReset);\n URL.revokeObjectURL(url);\n };\n\n return { url, detach };\n}\n\n/**\n * Attach a MediaSource as a `` child element.\n *\n * The object URL rides a `` inserted as the\n * element's FIRST child (any bare `src` attribute is dropped; `load()`\n * re-runs resource selection). Unlike `srcObject`/`src` — which commit the\n * element to the MSE resource and ignore every `` child — this\n * keeps sibling `` alternatives part of resource selection, so a\n * composition can offer the element a natively-playable alternative next to\n * MSE. Canonical consumer: `setupAirPlay`'s native-HLS fallback source,\n * wired through `setupMediaSource`'s `attachMediaSource` config\n * (https://webkit.org/blog/15036/how-to-use-media-source-extensions-with-airplay/).\n */\nexport function attachMediaSourceAsSourceElement(\n mediaSource: MediaSource,\n mediaElement: HTMLMediaElement\n): AttachMediaSourceResult {\n // ManagedMediaSource requires disableRemotePlayback — without it Safari\n // will not fire sourceopen. (MMS-only: on other platforms the flag governs\n // the standard Remote Playback API and must be left alone.) Features that\n // need it false flip it once the source is open.\n if (supportsManagedMediaSource() && mediaSource instanceof ManagedMediaSource!) {\n mediaElement.disableRemotePlayback = true;\n }\n\n const url = URL.createObjectURL(mediaSource);\n const sourceEl = document.createElement('source');\n sourceEl.type = 'video/mp4';\n sourceEl.src = url;\n\n mediaElement.removeAttribute('src');\n mediaElement.prepend(sourceEl);\n mediaElement.load();\n\n const detach = ({ deferReset }: DetachOptions = {}): void => {\n sourceEl.remove();\n scheduleReset(mediaSource, mediaElement, url, deferReset);\n URL.revokeObjectURL(url);\n };\n\n return { url, detach };\n}\n\n/**\n * Detach's `load()` reset, applied only when tearing down an **unclosed**\n * attachment the element is still committed to:\n *\n * - **Closed MediaSource**: skip. The attachment is already dead, and the\n * element deliberately keeps whatever playback state it carries for\n * whatever attaches next — that attach's own `load()` performs the reset.\n * - **Element moved to another resource**: skip — resetting would rip that\n * resource out from under its owner.\n */\nfunction resetIfOwnedAndNotClosed(mediaSource: MediaSource, mediaElement: HTMLMediaElement, url: string): void {\n if (mediaSource.readyState !== 'closed' && mediaElement.currentSrc === url) {\n mediaElement.load();\n }\n}\n\n/**\n * Run the reset now, or on the next microtask when the caller has sibling\n * `` owners that clear on an effect (see {@link DetachOptions.deferReset}).\n *\n * Deferring is safe because removing this attachment's own source does not by\n * itself re-run resource selection: the element stays committed to the object\n * URL until something calls `load()`, so nothing starts playing in the gap.\n */\nfunction scheduleReset(\n mediaSource: MediaSource,\n mediaElement: HTMLMediaElement,\n url: string,\n deferReset: boolean | undefined\n): void {\n if (deferReset) {\n queueMicrotask(() => resetIfOwnedAndNotClosed(mediaSource, mediaElement, url));\n return;\n }\n resetIfOwnedAndNotClosed(mediaSource, mediaElement, url);\n}\n\n/**\n * Create a SourceBuffer on a MediaSource.\n *\n * @param mediaSource - The MediaSource (must be in 'open' state)\n * @param mimeCodec - MIME type with codecs (e.g., 'video/mp4; codecs=\"avc1.42E01E\"')\n * @returns The created SourceBuffer\n * @throws Error if MediaSource is not open or codec is unsupported\n *\n * @example\n * const buffer = createSourceBuffer(mediaSource, 'video/mp4; codecs=\"avc1.42E01E\"');\n */\nexport function createSourceBuffer(mediaSource: MediaSource, mimeCodec: string): SourceBuffer {\n if (mediaSource.readyState !== 'open') {\n throw new Error('MediaSource is not open');\n }\n\n if (!isCodecSupported(mimeCodec)) {\n throw new Error(`Codec not supported: ${mimeCodec}`);\n }\n\n return mediaSource.addSourceBuffer(mimeCodec);\n}\n\n/**\n * Build a MIME codec string from a track's `mimeType` + `codecs`. Works\n * on partially-resolved tracks — both fields come from the multivariant\n * playlist and are available before media-playlist resolution.\n *\n * @param track - Track carrying `mimeType` and `codecs`\n * @returns MIME codec string suitable for `MediaSource.addSourceBuffer`\n *\n * @example\n * buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] })\n * // => 'video/mp4; codecs=\"avc1.42E01E\"'\n */\nexport function buildMimeCodec(track: { mimeType: string; codecs?: string[] }): string {\n const codecString = track.codecs?.join(',') ?? '';\n return `${track.mimeType}; codecs=\"${codecString}\"`;\n}\n\n/**\n * Check if a codec is supported.\n *\n * @param mimeCodec - MIME type with codecs string\n * @returns True if the codec is supported\n *\n * @example\n * if (isCodecSupported('video/mp4; codecs=\"avc1.42E01E\"')) {\n * // Create source buffer\n * }\n */\nexport function isCodecSupported(mimeCodec: string): boolean {\n if (!supportsMediaSource()) {\n return false;\n }\n\n return MediaSource.isTypeSupported(mimeCodec);\n}\n\n/**\n * Observe `mediaSource.readyState` changes via DOM events.\n *\n * Listens to `sourceopen`, `sourceended`, and `sourceclose` and invokes\n * `onChange` with the current `readyState` after each event. Listeners\n * are automatically removed when `abortSignal` is aborted.\n *\n * @param mediaSource - The MediaSource to observe\n * @param abortSignal - AbortSignal that controls listener lifetime\n * @param onChange - Called with the current readyState after each change\n *\n * @example\n * const controller = new AbortController();\n * onMediaSourceReadyStateChange(mediaSource, controller.signal, (state) => {\n * if (state === 'open') { ... }\n * });\n * // Later: controller.abort();\n */\nexport function onMediaSourceReadyStateChange(\n mediaSource: MediaSource,\n abortSignal: AbortSignal,\n onChange: (readyState: MediaSource['readyState']) => void\n): void {\n const update = () => onChange(mediaSource.readyState);\n const options = { signal: abortSignal };\n mediaSource.addEventListener('sourceopen', update, options);\n mediaSource.addEventListener('sourceended', update, options);\n mediaSource.addEventListener('sourceclose', update, options);\n}\n\n/**\n * Wait until `mediaSource.readyState` transitions away from `'closed'`\n * (the next `sourceopen`/`sourceended`/`sourceclose` event), or until\n * `signal` aborts — whichever fires first.\n *\n * Resolves immediately when `readyState` is already `'open'` or `'ended'`\n * (no further transition is coming, so there's nothing to wait for). The\n * caller is expected to re-check `readyState` after the await to\n * distinguish `'open'` from terminal states.\n *\n * Companion to `onMediaSourceReadyStateChange` for one-shot use in async\n * sequences (e.g., a behavior's reactor entry that needs to wait for the\n * MediaSource to attach before performing a spec-conforming mutation).\n *\n * @example\n * await waitForMediaSourceOpen(mediaSource, signal);\n * if (signal.aborted || mediaSource.readyState !== 'open') return;\n * // safe to perform 'open'-state-only work\n */\nexport function waitForMediaSourceOpen(mediaSource: MediaSource, signal: AbortSignal): Promise {\n if (signal.aborted) return Promise.resolve();\n if (mediaSource.readyState !== 'closed') return Promise.resolve();\n\n return new Promise((resolve) => {\n const done = () => resolve();\n const options = { once: true, signal };\n mediaSource.addEventListener('sourceopen', done, options);\n mediaSource.addEventListener('sourceended', done, options);\n mediaSource.addEventListener('sourceclose', done, options);\n signal.addEventListener('abort', done, { once: true });\n });\n}\n"],"mappings":";;;;;;;;;;;;AAYA,SAAgB,sBAA+B;CAC7C,OAAO,OAAO,gBAAgB;AAChC;;;;;AAMA,SAAgB,6BAAsC;CACpD,OAAO,OAAO,uBAAuB;AACvC;;;;;;;;;;;;;AAsBA,SAAgB,kBAAkB,UAAoC,CAAC,GAAgB;CACrF,MAAM,EAAE,gBAAgB,UAAU;CAElC,IAAI,iBAAiB,2BAA2B,GAC9C,OAAO,IAAI,mBAAoB;CAGjC,IAAI,oBAAoB,GACtB,OAAO,IAAI,YAAY;CAGzB,MAAM,IAAI,MAAM,kCAAkC;AACpD;;;;;;;;;;;;;;;;;;;AAkDA,SAAgB,kBAAkB,aAA0B,cAAyD;CAInH,IAAI,2BAA2B,KAAK,uBAAuB,oBACzD,aAAa,wBAAwB;CAGvC,MAAM,MAAM,IAAI,gBAAgB,WAAW;CAC3C,aAAa,MAAM;CAEnB,MAAM,UAAU,EAAE,eAA8B,CAAC,MAAY;EAC3D,aAAa,gBAAgB,KAAK;EAClC,cAAc,aAAa,cAAc,KAAK,UAAU;EACxD,IAAI,gBAAgB,GAAG;CACzB;CAEA,OAAO;EAAE;EAAK;CAAO;AACvB;;;;;;;;;;;;;;AAeA,SAAgB,iCACd,aACA,cACyB;CAKzB,IAAI,2BAA2B,KAAK,uBAAuB,oBACzD,aAAa,wBAAwB;CAGvC,MAAM,MAAM,IAAI,gBAAgB,WAAW;CAC3C,MAAM,WAAW,SAAS,cAAc,QAAQ;CAChD,SAAS,OAAO;CAChB,SAAS,MAAM;CAEf,aAAa,gBAAgB,KAAK;CAClC,aAAa,QAAQ,QAAQ;CAC7B,aAAa,KAAK;CAElB,MAAM,UAAU,EAAE,eAA8B,CAAC,MAAY;EAC3D,SAAS,OAAO;EAChB,cAAc,aAAa,cAAc,KAAK,UAAU;EACxD,IAAI,gBAAgB,GAAG;CACzB;CAEA,OAAO;EAAE;EAAK;CAAO;AACvB;;;;;;;;;;;AAYA,SAAS,yBAAyB,aAA0B,cAAgC,KAAmB;CAC7G,IAAI,YAAY,eAAe,YAAY,aAAa,eAAe,KACrE,aAAa,KAAK;AAEtB;;;;;;;;;AAUA,SAAS,cACP,aACA,cACA,KACA,YACM;CACN,IAAI,YAAY;EACd,qBAAqB,yBAAyB,aAAa,cAAc,GAAG,CAAC;EAC7E;CACF;CACA,yBAAyB,aAAa,cAAc,GAAG;AACzD;;;;;;;;;;;;AAaA,SAAgB,mBAAmB,aAA0B,WAAiC;CAC5F,IAAI,YAAY,eAAe,QAC7B,MAAM,IAAI,MAAM,yBAAyB;CAG3C,IAAI,CAAC,iBAAiB,SAAS,GAC7B,MAAM,IAAI,MAAM,wBAAwB,WAAW;CAGrD,OAAO,YAAY,gBAAgB,SAAS;AAC9C;;;;;;;;;;;;;AAcA,SAAgB,eAAe,OAAwD;CACrF,MAAM,cAAc,MAAM,QAAQ,KAAK,GAAG,KAAK;CAC/C,OAAO,GAAG,MAAM,SAAS,YAAY,YAAY;AACnD;;;;;;;;;;;;AAaA,SAAgB,iBAAiB,WAA4B;CAC3D,IAAI,CAAC,oBAAoB,GACvB,OAAO;CAGT,OAAO,YAAY,gBAAgB,SAAS;AAC9C;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,8BACd,aACA,aACA,UACM;CACN,MAAM,eAAe,SAAS,YAAY,UAAU;CACpD,MAAM,UAAU,EAAE,QAAQ,YAAY;CACtC,YAAY,iBAAiB,cAAc,QAAQ,OAAO;CAC1D,YAAY,iBAAiB,eAAe,QAAQ,OAAO;CAC3D,YAAY,iBAAiB,eAAe,QAAQ,OAAO;AAC7D;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,uBAAuB,aAA0B,QAAoC;CACnG,IAAI,OAAO,SAAS,OAAO,QAAQ,QAAQ;CAC3C,IAAI,YAAY,eAAe,UAAU,OAAO,QAAQ,QAAQ;CAEhE,OAAO,IAAI,SAAe,YAAY;EACpC,MAAM,aAAa,QAAQ;EAC3B,MAAM,UAAU;GAAE,MAAM;GAAM;EAAO;EACrC,YAAY,iBAAiB,cAAc,MAAM,OAAO;EACxD,YAAY,iBAAiB,eAAe,MAAM,OAAO;EACzD,YAAY,iBAAiB,eAAe,MAAM,OAAO;EACzD,OAAO,iBAAiB,SAAS,MAAM,EAAE,MAAM,KAAK,CAAC;CACvD,CAAC;AACH"} \ No newline at end of file diff --git a/dist/default/media/dom/text/resolve-vtt-segment.js b/dist/default/media/dom/text/resolve-vtt-segment.js new file mode 100644 index 00000000..89de654c --- /dev/null +++ b/dist/default/media/dom/text/resolve-vtt-segment.js @@ -0,0 +1,50 @@ +//#region src/media/dom/text/resolve-vtt-segment.ts +let dummyVideo = null; +function ensureDummyVideo() { + if (!dummyVideo) { + dummyVideo = document.createElement("video"); + dummyVideo.muted = true; + dummyVideo.preload = "none"; + dummyVideo.style.display = "none"; + dummyVideo.crossOrigin = "anonymous"; + } + return dummyVideo; +} +function resolveVttSegment(url) { + const video = ensureDummyVideo(); + const track = document.createElement("track"); + track.kind = "subtitles"; + return new Promise((resolve, reject) => { + const onLoad = () => { + const cues = []; + const textTrack = track.track; + if (textTrack.cues) for (let i = 0; i < textTrack.cues.length; i++) { + const cue = textTrack.cues[i]; + if (cue) cues.push(cue); + } + cleanup(); + resolve(cues); + }; + const onError = () => { + cleanup(); + reject(/* @__PURE__ */ new Error(`Failed to load VTT segment: ${url}`)); + }; + const cleanup = () => { + track.removeEventListener("load", onLoad); + track.removeEventListener("error", onError); + video.removeChild(track); + }; + track.addEventListener("load", onLoad); + track.addEventListener("error", onError); + video.appendChild(track); + track.track.mode = "hidden"; + track.src = url; + }); +} +function destroyVttResolver() { + dummyVideo = null; +} +//#endregion +export { destroyVttResolver, resolveVttSegment }; + +//# sourceMappingURL=resolve-vtt-segment.js.map \ No newline at end of file diff --git a/dist/default/media/dom/text/resolve-vtt-segment.js.map b/dist/default/media/dom/text/resolve-vtt-segment.js.map new file mode 100644 index 00000000..3857a974 --- /dev/null +++ b/dist/default/media/dom/text/resolve-vtt-segment.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resolve-vtt-segment.js","names":[],"sources":["../../../../../src/media/dom/text/resolve-vtt-segment.ts"],"sourcesContent":["/**\n * Parse a VTT segment using browser's native parser.\n *\n * Creates a dummy video element with a track element to leverage\n * the browser's optimized VTT parsing. Returns parsed VTTCue objects.\n */\n\nimport { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../text/resolve-vtt-metadata';\n\n// Singleton dummy video (reused across all parsing)\nlet dummyVideo: HTMLVideoElement | null = null;\n\nfunction ensureDummyVideo(): HTMLVideoElement {\n if (!dummyVideo) {\n dummyVideo = document.createElement('video');\n dummyVideo.muted = true;\n dummyVideo.preload = 'none';\n dummyVideo.style.display = 'none';\n dummyVideo.crossOrigin = 'anonymous';\n }\n return dummyVideo;\n}\n\nexport function resolveVttSegment(url: string): Promise {\n const video = ensureDummyVideo();\n const track = document.createElement('track');\n track.kind = 'subtitles';\n\n return new Promise((resolve, reject) => {\n const onLoad = (): void => {\n const cues: VTTCue[] = [];\n const textTrack = track.track;\n\n if (textTrack.cues) {\n for (let i = 0; i < textTrack.cues.length; i++) {\n const cue = textTrack.cues[i];\n if (cue) {\n cues.push(cue as VTTCue);\n }\n }\n }\n\n cleanup();\n resolve(cues);\n };\n\n const onError = (): void => {\n cleanup();\n reject(new Error(`Failed to load VTT segment: ${url}`));\n };\n\n const cleanup = (): void => {\n track.removeEventListener('load', onLoad);\n track.removeEventListener('error', onError);\n video.removeChild(track);\n };\n\n track.addEventListener('load', onLoad);\n track.addEventListener('error', onError);\n video.appendChild(track);\n // Force the browser to load and parse THIS track's resource by activating\n // it explicitly. Relying on `default = true` only works for the first track\n // appended to the reused dummy video: the media element's automatic\n // text-track selection runs once, so on Firefox every subsequent ``\n // stays inactive, its resource is never fetched, and `load` never fires —\n // stranding all cues past the first segment. Setting `mode = 'hidden'`\n // (active but not rendered — this video is never shown) loads each segment\n // on every browser.\n track.track.mode = 'hidden';\n track.src = url;\n });\n}\n\nexport function destroyVttResolver(): void {\n dummyVideo = null;\n}\n\n/**\n * A resolved VTT segment paired with its header metadata — the shape used when a\n * caller needs the `X-TIMESTAMP-MAP` correlation (e.g. non-zero-PTS sources),\n * not just the cues.\n */\nexport interface ResolvedVttSegment {\n cues: VTTCue[];\n metadata: TextSegmentMetadata;\n}\n\n/**\n * Resolve a VTT segment's cues and header metadata together. Cues still come\n * from the browser's native parser ({@link resolveVttSegment}); the header is\n * scraped in parallel ({@link resolveVttSegmentMetadata}).\n */\nexport function resolveVttSegmentWithMetadata(url: string): Promise {\n return Promise.all([resolveVttSegment(url), resolveVttSegmentMetadata(url)]).then(([cues, metadata]) => ({\n cues,\n metadata,\n }));\n}\n"],"mappings":";AAUA,IAAI,aAAsC;AAE1C,SAAS,mBAAqC;CAC5C,IAAI,CAAC,YAAY;EACf,aAAa,SAAS,cAAc,OAAO;EAC3C,WAAW,QAAQ;EACnB,WAAW,UAAU;EACrB,WAAW,MAAM,UAAU;EAC3B,WAAW,cAAc;CAC3B;CACA,OAAO;AACT;AAEA,SAAgB,kBAAkB,KAAgC;CAChE,MAAM,QAAQ,iBAAiB;CAC/B,MAAM,QAAQ,SAAS,cAAc,OAAO;CAC5C,MAAM,OAAO;CAEb,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,eAAqB;GACzB,MAAM,OAAiB,CAAC;GACxB,MAAM,YAAY,MAAM;GAExB,IAAI,UAAU,MACZ,KAAK,IAAI,IAAI,GAAG,IAAI,UAAU,KAAK,QAAQ,KAAK;IAC9C,MAAM,MAAM,UAAU,KAAK;IAC3B,IAAI,KACF,KAAK,KAAK,GAAa;GAE3B;GAGF,QAAQ;GACR,QAAQ,IAAI;EACd;EAEA,MAAM,gBAAsB;GAC1B,QAAQ;GACR,uBAAO,IAAI,MAAM,+BAA+B,KAAK,CAAC;EACxD;EAEA,MAAM,gBAAsB;GAC1B,MAAM,oBAAoB,QAAQ,MAAM;GACxC,MAAM,oBAAoB,SAAS,OAAO;GAC1C,MAAM,YAAY,KAAK;EACzB;EAEA,MAAM,iBAAiB,QAAQ,MAAM;EACrC,MAAM,iBAAiB,SAAS,OAAO;EACvC,MAAM,YAAY,KAAK;EASvB,MAAM,MAAM,OAAO;EACnB,MAAM,MAAM;CACd,CAAC;AACH;AAEA,SAAgB,qBAA2B;CACzC,aAAa;AACf"} \ No newline at end of file diff --git a/dist/default/media/dom/text/text-track-slots.js b/dist/default/media/dom/text/text-track-slots.js new file mode 100644 index 00000000..df9af8da --- /dev/null +++ b/dist/default/media/dom/text/text-track-slots.js @@ -0,0 +1,69 @@ +import { isCaptionOrSubtitleTrack } from "@videojs/utils/dom"; +//#region src/media/dom/text/text-track-slots.ts +/** +* SPF-owned `` selector. Each slot created by +* `addSubtitlesTracksToMedia` carries this attribute so reads and removals can +* filter SPF-owned tracks from host-page-owned ones. +*/ +const SPF_TRACK_SELECTOR = "track[data-src-track]"; +/** +* Allocate text-track slots on `mediaElement` for each model track by creating +* and appending `` children. Marks each element with `data-src-track` +* so it can be distinguished from `` children the host page added +* directly — used by `getShowingSubtitlesTrackFromMedia` and +* `removeAllSubtitlesTracksFromMedia` to scope their reads/removals to +* SPF-owned slots. The spec has no `removeTextTrack` API, so creating +* `` elements is the only mechanism for adding *and* removing entries +* to `mediaElement.textTracks`. +*/ +function addSubtitlesTracksToMedia(mediaElement, modelTextTracks) { + for (const modelTrack of modelTextTracks) { + const el = document.createElement("track"); + el.id = modelTrack.id; + el.kind = modelTrack.kind; + el.label = modelTrack.label; + el.toggleAttribute("data-src-track", true); + if (modelTrack.language) el.srclang = modelTrack.language; + mediaElement.appendChild(el); + } +} +/** +* Return the SPF-owned subtitle/caption `TextTrack` currently in `'showing'` +* mode, or `undefined` if none. Restricts the search to slots created by +* `addSubtitlesTracksToMedia` (via the `data-src-track` selector) so a showing +* track that the host page added directly is ignored — SPF selection only +* mirrors tracks it owns. +*/ +function getShowingSubtitlesTrackFromMedia(mediaElement) { + const elements = mediaElement.querySelectorAll(SPF_TRACK_SELECTOR); + for (const el of elements) { + const track = el.track; + if (track.mode === "showing" && isCaptionOrSubtitleTrack(track)) return track; + } +} +/** +* Remove every SPF-owned `` child from `mediaElement` (those tagged +* with `data-src-track` by `addSubtitlesTracksToMedia`). `` elements +* the host page added directly are left in place. +*/ +function removeAllSubtitlesTracksFromMedia(mediaElement) { + const elements = mediaElement.querySelectorAll(SPF_TRACK_SELECTOR); + for (const el of elements) el.remove(); +} +/** +* Apply a selection to a `TextTrackList` by setting each subtitle/caption +* track's `mode` to `'showing'` if its `id` matches `selectedId` and +* `'disabled'` otherwise. Tracks of other kinds (chapters, metadata, +* descriptions) are left untouched — they may be owned by the host page. +*/ +function syncTextTrackModes(textTracks, selectedId) { + for (let i = 0; i < textTracks.length; i++) { + const track = textTracks[i]; + if (!isCaptionOrSubtitleTrack(track)) continue; + track.mode = track.id === selectedId ? "showing" : "disabled"; + } +} +//#endregion +export { addSubtitlesTracksToMedia, getShowingSubtitlesTrackFromMedia, removeAllSubtitlesTracksFromMedia, syncTextTrackModes }; + +//# sourceMappingURL=text-track-slots.js.map \ No newline at end of file diff --git a/dist/default/media/dom/text/text-track-slots.js.map b/dist/default/media/dom/text/text-track-slots.js.map new file mode 100644 index 00000000..a2c3e43d --- /dev/null +++ b/dist/default/media/dom/text/text-track-slots.js.map @@ -0,0 +1 @@ +{"version":3,"file":"text-track-slots.js","names":[],"sources":["../../../../../src/media/dom/text/text-track-slots.ts"],"sourcesContent":["import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom';\n\nimport type { PartiallyResolvedTextTrack, TextTrack } from '../../types';\n\n/**\n * SPF-owned `` selector. Each slot created by\n * `addSubtitlesTracksToMedia` carries this attribute so reads and removals can\n * filter SPF-owned tracks from host-page-owned ones.\n */\nconst SPF_TRACK_SELECTOR = 'track[data-src-track]';\n\n/**\n * Allocate text-track slots on `mediaElement` for each model track by creating\n * and appending `` children. Marks each element with `data-src-track`\n * so it can be distinguished from `` children the host page added\n * directly — used by `getShowingSubtitlesTrackFromMedia` and\n * `removeAllSubtitlesTracksFromMedia` to scope their reads/removals to\n * SPF-owned slots. The spec has no `removeTextTrack` API, so creating\n * `` elements is the only mechanism for adding *and* removing entries\n * to `mediaElement.textTracks`.\n */\nexport function addSubtitlesTracksToMedia(\n mediaElement: HTMLMediaElement,\n modelTextTracks: readonly (PartiallyResolvedTextTrack | TextTrack)[]\n): void {\n for (const modelTrack of modelTextTracks) {\n const el = document.createElement('track');\n el.id = modelTrack.id;\n el.kind = modelTrack.kind;\n el.label = modelTrack.label;\n el.toggleAttribute('data-src-track', true);\n if (modelTrack.language) el.srclang = modelTrack.language;\n // Deliberately NOT propagating `modelTrack.default` to the `default`\n // attribute: that makes the browser auto-activate the slot on insertion,\n // which fires a `change` that `syncTextTracks` records as user intent —\n // auto-enabling captions past SPF's opt-in policy (`enableDefaultTrack`\n // governs DEFAULT=YES handling in `switchTextTrack`, not the browser). SPF\n // owns selection; these slots are containers, so they carry no selection hint.\n mediaElement.appendChild(el);\n }\n}\n\n/**\n * Return the SPF-owned subtitle/caption `TextTrack` currently in `'showing'`\n * mode, or `undefined` if none. Restricts the search to slots created by\n * `addSubtitlesTracksToMedia` (via the `data-src-track` selector) so a showing\n * track that the host page added directly is ignored — SPF selection only\n * mirrors tracks it owns.\n */\nexport function getShowingSubtitlesTrackFromMedia(mediaElement: HTMLMediaElement): globalThis.TextTrack | undefined {\n const elements = mediaElement.querySelectorAll(SPF_TRACK_SELECTOR);\n for (const el of elements) {\n const track = el.track;\n if (track.mode === 'showing' && isCaptionOrSubtitleTrack(track)) {\n return track;\n }\n }\n return undefined;\n}\n\n/**\n * Remove every SPF-owned `` child from `mediaElement` (those tagged\n * with `data-src-track` by `addSubtitlesTracksToMedia`). `` elements\n * the host page added directly are left in place.\n */\nexport function removeAllSubtitlesTracksFromMedia(mediaElement: HTMLMediaElement): void {\n const elements = mediaElement.querySelectorAll(SPF_TRACK_SELECTOR);\n for (const el of elements) {\n el.remove();\n }\n}\n\n/**\n * Apply a selection to a `TextTrackList` by setting each subtitle/caption\n * track's `mode` to `'showing'` if its `id` matches `selectedId` and\n * `'disabled'` otherwise. Tracks of other kinds (chapters, metadata,\n * descriptions) are left untouched — they may be owned by the host page.\n */\nexport function syncTextTrackModes(textTracks: TextTrackList, selectedId: string | undefined): void {\n for (let i = 0; i < textTracks.length; i++) {\n const track = textTracks[i]!;\n if (!isCaptionOrSubtitleTrack(track)) continue;\n track.mode = track.id === selectedId ? 'showing' : 'disabled';\n }\n}\n"],"mappings":";;;;;;;AASA,MAAM,qBAAqB;;;;;;;;;;;AAY3B,SAAgB,0BACd,cACA,iBACM;CACN,KAAK,MAAM,cAAc,iBAAiB;EACxC,MAAM,KAAK,SAAS,cAAc,OAAO;EACzC,GAAG,KAAK,WAAW;EACnB,GAAG,OAAO,WAAW;EACrB,GAAG,QAAQ,WAAW;EACtB,GAAG,gBAAgB,kBAAkB,IAAI;EACzC,IAAI,WAAW,UAAU,GAAG,UAAU,WAAW;EAOjD,aAAa,YAAY,EAAE;CAC7B;AACF;;;;;;;;AASA,SAAgB,kCAAkC,cAAkE;CAClH,MAAM,WAAW,aAAa,iBAAmC,kBAAkB;CACnF,KAAK,MAAM,MAAM,UAAU;EACzB,MAAM,QAAQ,GAAG;EACjB,IAAI,MAAM,SAAS,aAAa,yBAAyB,KAAK,GAC5D,OAAO;CAEX;AAEF;;;;;;AAOA,SAAgB,kCAAkC,cAAsC;CACtF,MAAM,WAAW,aAAa,iBAAmC,kBAAkB;CACnF,KAAK,MAAM,MAAM,UACf,GAAG,OAAO;AAEd;;;;;;;AAQA,SAAgB,mBAAmB,YAA2B,YAAsC;CAClG,KAAK,IAAI,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;EAC1C,MAAM,QAAQ,WAAW;EACzB,IAAI,CAAC,yBAAyB,KAAK,GAAG;EACtC,MAAM,OAAO,MAAM,OAAO,aAAa,YAAY;CACrD;AACF"} \ No newline at end of file diff --git a/dist/default/media/hls/parse-attributes.js b/dist/default/media/hls/parse-attributes.js new file mode 100644 index 00000000..cb16e462 --- /dev/null +++ b/dist/default/media/hls/parse-attributes.js @@ -0,0 +1,148 @@ +//#region src/media/hls/parse-attributes.ts +/** +* Parse HLS attribute list from a tag line. +* Handles both quoted and unquoted values. +*/ +function parseAttributeList(line) { + const attributes = /* @__PURE__ */ new Map(); + for (const match of line.matchAll(/([A-Z0-9-]+)=(?:"([^"]*)"|([^,]*))/g)) { + const key = match[1]; + const value = match[2] ?? match[3] ?? ""; + if (key) attributes.set(key, value); + } + return attributes; +} +/** +* Parse RESOLUTION attribute value (WIDTHxHEIGHT). +*/ +function parseResolution(value) { + const match = /^(\d+)x(\d+)$/.exec(value); + if (!match) return null; + return { + width: Number.parseInt(match[1], 10), + height: Number.parseInt(match[2], 10) + }; +} +/** +* Parse FRAME-RATE attribute to rational frame rate. +*/ +function parseFrameRate(value) { + const fps = Number.parseFloat(value); + if (Number.isNaN(fps) || fps <= 0) return void 0; + if (Math.abs(fps - 23.976) < .01) return { + frameRateNumerator: 24e3, + frameRateDenominator: 1001 + }; + if (Math.abs(fps - 29.97) < .01) return { + frameRateNumerator: 3e4, + frameRateDenominator: 1001 + }; + if (Math.abs(fps - 59.94) < .01) return { + frameRateNumerator: 6e4, + frameRateDenominator: 1001 + }; + if (fps % 1 === 0) return { frameRateNumerator: Math.round(fps) }; + return { frameRateNumerator: Math.round(fps) }; +} +const AUDIO_CODEC_PREFIXES = [ + "mp4a.", + "ac-3", + "ec-3", + "ac-4", + "opus", + "flac", + "dts", + "alac", + "vorbis" +]; +/** +* Parse CODECS attribute into separate video and audio codecs. +*/ +function parseCodecs(codecs) { + const parts = codecs.split(",").map((s) => s.trim()); + const result = {}; + for (const codec of parts) { + const lower = codec.toLowerCase(); + if (codec.startsWith("avc1.") || codec.startsWith("hvc1.") || codec.startsWith("hev1.")) result.video = codec; + else if (AUDIO_CODEC_PREFIXES.some((prefix) => lower.startsWith(prefix))) result.audio = codec; + } + return result; +} +/** +* Parse #EXTINF duration value. +*/ +function parseExtInfDuration(value) { + const durationPart = value.split(",")[0] ?? value; + const duration = Number.parseFloat(durationPart); + return Number.isNaN(duration) ? 0 : duration; +} +/** +* Parse BYTERANGE attribute value. +* Format: "length[@offset]" +* If offset is omitted, it continues from the previous byte range end. +*/ +function parseByteRange(value, previousEnd) { + const match = /^(\d+)(?:@(\d+))?$/.exec(value); + if (!match) return null; + const length = Number.parseInt(match[1], 10); + if (Number.isNaN(length)) return null; + let start; + if (match[2] !== void 0) { + start = Number.parseInt(match[2], 10); + if (Number.isNaN(start)) return null; + } else if (previousEnd !== void 0) start = previousEnd; + else return null; + return { + start, + end: start + length - 1 + }; +} +/** +* Create AttributeList from raw attribute string. +*/ +function createAttributeList(line) { + const map = parseAttributeList(line); + return { + get(key) { + return map.get(key); + }, + getInt(key, defaultValue) { + const value = map.get(key); + if (value === void 0) return defaultValue; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getFloat(key, defaultValue) { + const value = map.get(key); + if (value === void 0) return defaultValue; + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? defaultValue : parsed; + }, + getBool(key) { + return map.get(key) === "YES"; + }, + getResolution(key) { + const value = map.get(key); + if (!value) return void 0; + return parseResolution(value) ?? void 0; + }, + getFrameRate(key) { + const value = map.get(key); + if (!value) return void 0; + return parseFrameRate(value); + } + }; +} +/** +* Match a tag and extract its attributes. +* Returns null if the line doesn't match the tag. +*/ +function matchTag(line, tag) { + const prefix = `#${tag}:`; + if (!line.startsWith(prefix)) return null; + return createAttributeList(line.slice(prefix.length)); +} +//#endregion +export { createAttributeList, matchTag, parseAttributeList, parseByteRange, parseCodecs, parseExtInfDuration, parseFrameRate, parseResolution }; + +//# sourceMappingURL=parse-attributes.js.map \ No newline at end of file diff --git a/dist/default/media/hls/parse-attributes.js.map b/dist/default/media/hls/parse-attributes.js.map new file mode 100644 index 00000000..1aad50ec --- /dev/null +++ b/dist/default/media/hls/parse-attributes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"parse-attributes.js","names":[],"sources":["../../../../src/media/hls/parse-attributes.ts"],"sourcesContent":["import type { FrameRate } from '../types';\n\n/**\n * Parse HLS attribute list from a tag line.\n * Handles both quoted and unquoted values.\n */\nexport function parseAttributeList(line: string): Map {\n const attributes = new Map();\n const regex = /([A-Z0-9-]+)=(?:\"([^\"]*)\"|([^,]*))/g;\n\n for (const match of line.matchAll(regex)) {\n const key = match[1];\n const value = match[2] ?? match[3] ?? '';\n if (key) {\n attributes.set(key, value);\n }\n }\n\n return attributes;\n}\n\n/**\n * Parse RESOLUTION attribute value (WIDTHxHEIGHT).\n */\nexport function parseResolution(value: string): { width: number; height: number } | null {\n const match = /^(\\d+)x(\\d+)$/.exec(value);\n if (!match) return null;\n\n const width = Number.parseInt(match[1]!, 10);\n const height = Number.parseInt(match[2]!, 10);\n\n return { width, height };\n}\n\n/**\n * Parse FRAME-RATE attribute to rational frame rate.\n */\nexport function parseFrameRate(value: string): FrameRate | undefined {\n const fps = Number.parseFloat(value);\n if (Number.isNaN(fps) || fps <= 0) return undefined;\n\n // Common frame rates with tolerance for floating point precision\n if (Math.abs(fps - 23.976) < 0.01) {\n return { frameRateNumerator: 24000, frameRateDenominator: 1001 };\n }\n if (Math.abs(fps - 29.97) < 0.01) {\n return { frameRateNumerator: 30000, frameRateDenominator: 1001 };\n }\n if (Math.abs(fps - 59.94) < 0.01) {\n return { frameRateNumerator: 60000, frameRateDenominator: 1001 };\n }\n\n // Integer frame rates\n if (fps % 1 === 0) {\n return { frameRateNumerator: Math.round(fps) };\n }\n\n // Default: use rounded value\n return { frameRateNumerator: Math.round(fps) };\n}\n\n// Audio codec identifiers, matched case-insensitively against each CODECS\n// entry's prefix. Beyond AAC (`mp4a.*`): Dolby (`ac-3`, `ec-3`, `ac-4`), Opus,\n// FLAC (`fLaC`), DTS (`dts*`), ALAC, and Vorbis. Recognizing these is what lets\n// capability probing filter undecodable audio renditions (e.g. an AC-3 5.1\n// track on a browser without AC-3) — an unrecognized codec parses empty and is\n// treated as unprobeable, so it would never be pruned.\nconst AUDIO_CODEC_PREFIXES = ['mp4a.', 'ac-3', 'ec-3', 'ac-4', 'opus', 'flac', 'dts', 'alac', 'vorbis'];\n\n/**\n * Parse CODECS attribute into separate video and audio codecs.\n */\nexport function parseCodecs(codecs: string): { video?: string; audio?: string } {\n const parts = codecs.split(',').map((s) => s.trim());\n const result: { video?: string; audio?: string } = {};\n\n for (const codec of parts) {\n const lower = codec.toLowerCase();\n if (codec.startsWith('avc1.') || codec.startsWith('hvc1.') || codec.startsWith('hev1.')) {\n result.video = codec;\n } else if (AUDIO_CODEC_PREFIXES.some((prefix) => lower.startsWith(prefix))) {\n result.audio = codec;\n }\n }\n\n return result;\n}\n\n/**\n * Parse #EXTINF duration value.\n */\nexport function parseExtInfDuration(value: string): number {\n const durationPart = value.split(',')[0] ?? value;\n const duration = Number.parseFloat(durationPart);\n return Number.isNaN(duration) ? 0 : duration;\n}\n\n/**\n * Parse BYTERANGE attribute value.\n * Format: \"length[@offset]\"\n * If offset is omitted, it continues from the previous byte range end.\n */\nexport function parseByteRange(value: string, previousEnd?: number): { start: number; end: number } | null {\n const match = /^(\\d+)(?:@(\\d+))?$/.exec(value);\n if (!match) return null;\n\n const length = Number.parseInt(match[1]!, 10);\n if (Number.isNaN(length)) return null;\n\n let start: number;\n if (match[2] !== undefined) {\n start = Number.parseInt(match[2], 10);\n if (Number.isNaN(start)) return null;\n } else if (previousEnd !== undefined) {\n start = previousEnd;\n } else {\n return null;\n }\n\n return { start, end: start + length - 1 };\n}\n\n/**\n * AttributeList - Typed attribute access wrapper.\n */\nexport interface AttributeList {\n get: (key: string) => string | undefined;\n getInt: (key: string, defaultValue?: number) => number | undefined;\n getFloat: (key: string, defaultValue?: number) => number | undefined;\n getBool: (key: string) => boolean;\n getResolution: (key: string) => { width: number; height: number } | undefined;\n getFrameRate: (key: string) => FrameRate | undefined;\n}\n\n/**\n * Create AttributeList from raw attribute string.\n */\nexport function createAttributeList(line: string): AttributeList {\n const map = parseAttributeList(line);\n\n return {\n get(key: string): string | undefined {\n return map.get(key);\n },\n\n getInt(key: string, defaultValue?: number): number | undefined {\n const value = map.get(key);\n if (value === undefined) return defaultValue;\n const parsed = Number.parseInt(value, 10);\n return Number.isNaN(parsed) ? defaultValue : parsed;\n },\n\n getFloat(key: string, defaultValue?: number): number | undefined {\n const value = map.get(key);\n if (value === undefined) return defaultValue;\n const parsed = Number.parseFloat(value);\n return Number.isNaN(parsed) ? defaultValue : parsed;\n },\n\n getBool(key: string): boolean {\n return map.get(key) === 'YES';\n },\n\n getResolution(key: string): { width: number; height: number } | undefined {\n const value = map.get(key);\n if (!value) return undefined;\n return parseResolution(value) ?? undefined;\n },\n\n getFrameRate(key: string): FrameRate | undefined {\n const value = map.get(key);\n if (!value) return undefined;\n return parseFrameRate(value);\n },\n };\n}\n\n/**\n * Match a tag and extract its attributes.\n * Returns null if the line doesn't match the tag.\n */\nexport function matchTag(line: string, tag: string): AttributeList | null {\n const prefix = `#${tag}:`;\n if (!line.startsWith(prefix)) return null;\n return createAttributeList(line.slice(prefix.length));\n}\n"],"mappings":";;;;;AAMA,SAAgB,mBAAmB,MAAmC;CACpE,MAAM,6BAAa,IAAI,IAAoB;CAG3C,KAAK,MAAM,SAAS,KAAK,SAAS,qCAAK,GAAG;EACxC,MAAM,MAAM,MAAM;EAClB,MAAM,QAAQ,MAAM,MAAM,MAAM,MAAM;EACtC,IAAI,KACF,WAAW,IAAI,KAAK,KAAK;CAE7B;CAEA,OAAO;AACT;;;;AAKA,SAAgB,gBAAgB,OAAyD;CACvF,MAAM,QAAQ,gBAAgB,KAAK,KAAK;CACxC,IAAI,CAAC,OAAO,OAAO;CAKnB,OAAO;EAAE,OAHK,OAAO,SAAS,MAAM,IAAK,EAG5B;EAAG,QAFD,OAAO,SAAS,MAAM,IAAK,EAErB;CAAE;AACzB;;;;AAKA,SAAgB,eAAe,OAAsC;CACnE,MAAM,MAAM,OAAO,WAAW,KAAK;CACnC,IAAI,OAAO,MAAM,GAAG,KAAK,OAAO,GAAG,OAAO,KAAA;CAG1C,IAAI,KAAK,IAAI,MAAM,MAAM,IAAI,KAC3B,OAAO;EAAE,oBAAoB;EAAO,sBAAsB;CAAK;CAEjE,IAAI,KAAK,IAAI,MAAM,KAAK,IAAI,KAC1B,OAAO;EAAE,oBAAoB;EAAO,sBAAsB;CAAK;CAEjE,IAAI,KAAK,IAAI,MAAM,KAAK,IAAI,KAC1B,OAAO;EAAE,oBAAoB;EAAO,sBAAsB;CAAK;CAIjE,IAAI,MAAM,MAAM,GACd,OAAO,EAAE,oBAAoB,KAAK,MAAM,GAAG,EAAE;CAI/C,OAAO,EAAE,oBAAoB,KAAK,MAAM,GAAG,EAAE;AAC/C;AAQA,MAAM,uBAAuB;CAAC;CAAS;CAAQ;CAAQ;CAAQ;CAAQ;CAAQ;CAAO;CAAQ;AAAQ;;;;AAKtG,SAAgB,YAAY,QAAoD;CAC9E,MAAM,QAAQ,OAAO,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM,EAAE,KAAK,CAAC;CACnD,MAAM,SAA6C,CAAC;CAEpD,KAAK,MAAM,SAAS,OAAO;EACzB,MAAM,QAAQ,MAAM,YAAY;EAChC,IAAI,MAAM,WAAW,OAAO,KAAK,MAAM,WAAW,OAAO,KAAK,MAAM,WAAW,OAAO,GACpF,OAAO,QAAQ;OACV,IAAI,qBAAqB,MAAM,WAAW,MAAM,WAAW,MAAM,CAAC,GACvE,OAAO,QAAQ;CAEnB;CAEA,OAAO;AACT;;;;AAKA,SAAgB,oBAAoB,OAAuB;CACzD,MAAM,eAAe,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM;CAC5C,MAAM,WAAW,OAAO,WAAW,YAAY;CAC/C,OAAO,OAAO,MAAM,QAAQ,IAAI,IAAI;AACtC;;;;;;AAOA,SAAgB,eAAe,OAAe,aAA6D;CACzG,MAAM,QAAQ,qBAAqB,KAAK,KAAK;CAC7C,IAAI,CAAC,OAAO,OAAO;CAEnB,MAAM,SAAS,OAAO,SAAS,MAAM,IAAK,EAAE;CAC5C,IAAI,OAAO,MAAM,MAAM,GAAG,OAAO;CAEjC,IAAI;CACJ,IAAI,MAAM,OAAO,KAAA,GAAW;EAC1B,QAAQ,OAAO,SAAS,MAAM,IAAI,EAAE;EACpC,IAAI,OAAO,MAAM,KAAK,GAAG,OAAO;CAClC,OAAO,IAAI,gBAAgB,KAAA,GACzB,QAAQ;MAER,OAAO;CAGT,OAAO;EAAE;EAAO,KAAK,QAAQ,SAAS;CAAE;AAC1C;;;;AAiBA,SAAgB,oBAAoB,MAA6B;CAC/D,MAAM,MAAM,mBAAmB,IAAI;CAEnC,OAAO;EACL,IAAI,KAAiC;GACnC,OAAO,IAAI,IAAI,GAAG;EACpB;EAEA,OAAO,KAAa,cAA2C;GAC7D,MAAM,QAAQ,IAAI,IAAI,GAAG;GACzB,IAAI,UAAU,KAAA,GAAW,OAAO;GAChC,MAAM,SAAS,OAAO,SAAS,OAAO,EAAE;GACxC,OAAO,OAAO,MAAM,MAAM,IAAI,eAAe;EAC/C;EAEA,SAAS,KAAa,cAA2C;GAC/D,MAAM,QAAQ,IAAI,IAAI,GAAG;GACzB,IAAI,UAAU,KAAA,GAAW,OAAO;GAChC,MAAM,SAAS,OAAO,WAAW,KAAK;GACtC,OAAO,OAAO,MAAM,MAAM,IAAI,eAAe;EAC/C;EAEA,QAAQ,KAAsB;GAC5B,OAAO,IAAI,IAAI,GAAG,MAAM;EAC1B;EAEA,cAAc,KAA4D;GACxE,MAAM,QAAQ,IAAI,IAAI,GAAG;GACzB,IAAI,CAAC,OAAO,OAAO,KAAA;GACnB,OAAO,gBAAgB,KAAK,KAAK,KAAA;EACnC;EAEA,aAAa,KAAoC;GAC/C,MAAM,QAAQ,IAAI,IAAI,GAAG;GACzB,IAAI,CAAC,OAAO,OAAO,KAAA;GACnB,OAAO,eAAe,KAAK;EAC7B;CACF;AACF;;;;;AAMA,SAAgB,SAAS,MAAc,KAAmC;CACxE,MAAM,SAAS,IAAI,IAAI;CACvB,IAAI,CAAC,KAAK,WAAW,MAAM,GAAG,OAAO;CACrC,OAAO,oBAAoB,KAAK,MAAM,OAAO,MAAM,CAAC;AACtD"} \ No newline at end of file diff --git a/dist/default/media/hls/parse-media-playlist.js b/dist/default/media/hls/parse-media-playlist.js new file mode 100644 index 00000000..9618e25d --- /dev/null +++ b/dist/default/media/hls/parse-media-playlist.js @@ -0,0 +1,110 @@ +import { matchTag, parseByteRange, parseExtInfDuration } from "./parse-attributes.js"; +import { resolveUrl } from "./resolve-url.js"; +//#region src/media/hls/parse-media-playlist.ts +/** MPEG-2 Transport Stream (IANA `video/MP2T`, lowercased for `isTypeSupported`). Video + audio TS — there is no `audio/mp2t`. */ +const MPEG_TS_MIME = "video/mp2t"; +/** Raw ADTS AAC packed-audio (HLS `.aac` segments; IANA `audio/aac`). */ +const RAW_AAC_MIME = "audio/aac"; +const CONTAINER_MIME_BY_EXTENSION = { + ".ts": MPEG_TS_MIME, + ".aac": RAW_AAC_MIME +}; +/** The non-fMP4 container MIMEs the parser detects — all currently treated as unplayable. */ +const NON_FMP4_CONTAINER_MIMES = new Set(Object.values(CONTAINER_MIME_BY_EXTENSION)); +/** +* Non-fMP4 container MIME for a (resolved, absolute) segment URL, by file +* extension, ignoring the query string. `undefined` for fMP4 / unrecognized. +*/ +function containerMimeFromSegment(url) { + if (!url) return void 0; + let path; + try { + path = new URL(url).pathname.toLowerCase(); + } catch { + path = url.toLowerCase().split("?")[0] ?? ""; + } + const dot = path.lastIndexOf("."); + return dot === -1 ? void 0 : CONTAINER_MIME_BY_EXTENSION[path.slice(dot)]; +} +/** +* Parse HLS media playlist and resolve track with segments. +* +* Takes an unresolved track (from multivariant playlist) and media playlist text, +* returns a HAM-compliant resolved track with segments. +* +* @param text - Media playlist text content +* @param unresolved - Unresolved track from parseMultivariantPlaylist +* @returns Resolved track with segments (type inferred from input) +*/ +function parseMediaPlaylist(text, unresolved) { + const lines = text.split(/\r?\n/); + const baseUrl = unresolved.url; + const segments = []; + let initSegmentUrl; + let initSegmentByteRange; + let currentDuration = 0; + let currentByteRange; + let currentTime = 0; + let segmentIndex = 0; + let previousByteRangeEnd; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") && !trimmed.startsWith("#EXT")) continue; + if (trimmed === "#EXTM3U" || trimmed.startsWith("#EXT-X-VERSION:") || trimmed.startsWith("#EXT-X-TARGETDURATION:") || trimmed.startsWith("#EXT-X-PLAYLIST-TYPE:") || trimmed.startsWith("#EXT-X-INDEPENDENT-SEGMENTS")) continue; + const mapAttrs = matchTag(trimmed, "EXT-X-MAP"); + if (mapAttrs) { + const uri = mapAttrs.get("URI"); + if (uri) { + initSegmentUrl = resolveUrl(uri, baseUrl); + const byteRangeStr = mapAttrs.get("BYTERANGE"); + if (byteRangeStr) initSegmentByteRange = parseByteRange(byteRangeStr, 0) ?? void 0; + } + continue; + } + if (trimmed.startsWith("#EXTINF:")) { + currentDuration = parseExtInfDuration(trimmed.slice(8)); + continue; + } + if (trimmed.startsWith("#EXT-X-BYTERANGE:")) { + currentByteRange = parseByteRange(trimmed.slice(17), previousByteRangeEnd) ?? void 0; + continue; + } + if (trimmed === "#EXT-X-ENDLIST") continue; + if (!trimmed.startsWith("#") && currentDuration > 0) { + const segment = { + id: `segment-${segmentIndex}`, + url: resolveUrl(trimmed, baseUrl), + duration: currentDuration, + startTime: currentTime + }; + if (currentByteRange) { + segment.byteRange = currentByteRange; + previousByteRangeEnd = currentByteRange.end + 1; + } else previousByteRangeEnd = void 0; + segments.push(segment); + currentTime += currentDuration; + segmentIndex++; + currentDuration = 0; + currentByteRange = void 0; + } + } + const totalDuration = currentTime; + const initialization = unresolved.type === "text" && !initSegmentUrl ? void 0 : initSegmentUrl ? { + url: initSegmentUrl, + ...initSegmentByteRange ? { byteRange: initSegmentByteRange } : {} + } : { url: "" }; + const detectedContainer = initSegmentUrl ? void 0 : containerMimeFromSegment(segments[0]?.url); + const mimeType = unresolved.type !== "text" && detectedContainer ? detectedContainer : unresolved.mimeType; + return { + ...unresolved, + mimeType, + startTime: 0, + duration: totalDuration, + segments, + initialization + }; +} +//#endregion +export { MPEG_TS_MIME, NON_FMP4_CONTAINER_MIMES, RAW_AAC_MIME, parseMediaPlaylist }; + +//# sourceMappingURL=parse-media-playlist.js.map \ No newline at end of file diff --git a/dist/default/media/hls/parse-media-playlist.js.map b/dist/default/media/hls/parse-media-playlist.js.map new file mode 100644 index 00000000..f0e67d94 --- /dev/null +++ b/dist/default/media/hls/parse-media-playlist.js.map @@ -0,0 +1 @@ +{"version":3,"file":"parse-media-playlist.js","names":[],"sources":["../../../../src/media/hls/parse-media-playlist.ts"],"sourcesContent":["import type {\n AudioTrack,\n PartiallyResolvedAudioTrack,\n PartiallyResolvedTextTrack,\n PartiallyResolvedTrack,\n PartiallyResolvedVideoTrack,\n Segment,\n TextTrack,\n VideoTrack,\n} from '../types';\nimport { matchTag, parseByteRange, parseExtInfDuration } from './parse-attributes';\nimport { resolveUrl } from './resolve-url';\n\n/** MPEG-2 Transport Stream (IANA `video/MP2T`, lowercased for `isTypeSupported`). Video + audio TS — there is no `audio/mp2t`. */\nexport const MPEG_TS_MIME = 'video/mp2t';\n/** Raw ADTS AAC packed-audio (HLS `.aac` segments; IANA `audio/aac`). */\nexport const RAW_AAC_MIME = 'audio/aac';\n\n// Non-fMP4 container MIMEs keyed by segment file extension. fMP4 (the MSE\n// default) always carries an EXT-X-MAP init segment, so a media playlist with\n// no init segment and one of these extensions is a non-fMP4 rendition,\n// relabeled from the fMP4 default. Extend with `.mp3` → 'audio/mpeg' etc.\nconst CONTAINER_MIME_BY_EXTENSION: Record = {\n '.ts': MPEG_TS_MIME,\n '.aac': RAW_AAC_MIME,\n};\n\n/** The non-fMP4 container MIMEs the parser detects — all currently treated as unplayable. */\nexport const NON_FMP4_CONTAINER_MIMES = new Set(Object.values(CONTAINER_MIME_BY_EXTENSION));\n\n/**\n * Non-fMP4 container MIME for a (resolved, absolute) segment URL, by file\n * extension, ignoring the query string. `undefined` for fMP4 / unrecognized.\n */\nfunction containerMimeFromSegment(url: string | undefined): string | undefined {\n if (!url) return undefined;\n let path: string;\n try {\n path = new URL(url).pathname.toLowerCase();\n } catch {\n path = url.toLowerCase().split('?')[0] ?? '';\n }\n const dot = path.lastIndexOf('.');\n return dot === -1 ? undefined : CONTAINER_MIME_BY_EXTENSION[path.slice(dot)];\n}\n\n/**\n * Resolve unresolved track type to its resolved equivalent.\n */\ntype ResolveTrack = T extends PartiallyResolvedVideoTrack\n ? VideoTrack\n : T extends PartiallyResolvedAudioTrack\n ? AudioTrack\n : T extends PartiallyResolvedTextTrack\n ? TextTrack\n : never;\n\n/**\n * Parse HLS media playlist and resolve track with segments.\n *\n * Takes an unresolved track (from multivariant playlist) and media playlist text,\n * returns a HAM-compliant resolved track with segments.\n *\n * @param text - Media playlist text content\n * @param unresolved - Unresolved track from parseMultivariantPlaylist\n * @returns Resolved track with segments (type inferred from input)\n */\nexport function parseMediaPlaylist(\n text: string,\n unresolved: T | ResolveTrack\n): ResolveTrack {\n const lines = text.split(/\\r?\\n/);\n\n // Segments and resources resolve relative to media playlist URL (per HLS spec)\n const baseUrl = unresolved.url;\n\n // Parse playlist\n const segments: Segment[] = [];\n let initSegmentUrl: string | undefined;\n let initSegmentByteRange: { start: number; end: number } | undefined;\n\n let currentDuration = 0;\n let currentByteRange: { start: number; end: number } | undefined;\n let currentTime = 0;\n let segmentIndex = 0;\n let previousByteRangeEnd: number | undefined;\n\n for (const line of lines) {\n const trimmed = line.trim();\n\n if (!trimmed || (trimmed.startsWith('#') && !trimmed.startsWith('#EXT'))) {\n continue;\n }\n\n if (\n trimmed === '#EXTM3U' ||\n trimmed.startsWith('#EXT-X-VERSION:') ||\n trimmed.startsWith('#EXT-X-TARGETDURATION:') ||\n trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:') ||\n trimmed.startsWith('#EXT-X-INDEPENDENT-SEGMENTS')\n ) {\n continue;\n }\n\n // #EXT-X-MAP - Init segment\n const mapAttrs = matchTag(trimmed, 'EXT-X-MAP');\n if (mapAttrs) {\n const uri = mapAttrs.get('URI');\n if (uri) {\n initSegmentUrl = resolveUrl(uri, baseUrl);\n const byteRangeStr = mapAttrs.get('BYTERANGE');\n if (byteRangeStr) {\n initSegmentByteRange = parseByteRange(byteRangeStr, 0) ?? undefined;\n }\n }\n continue;\n }\n\n // #EXTINF - Segment duration\n if (trimmed.startsWith('#EXTINF:')) {\n currentDuration = parseExtInfDuration(trimmed.slice(8));\n continue;\n }\n\n // #EXT-X-BYTERANGE - Segment byte range\n if (trimmed.startsWith('#EXT-X-BYTERANGE:')) {\n currentByteRange = parseByteRange(trimmed.slice(17), previousByteRangeEnd) ?? undefined;\n continue;\n }\n\n if (trimmed === '#EXT-X-ENDLIST') {\n continue;\n }\n\n // Segment URI\n if (!trimmed.startsWith('#') && currentDuration > 0) {\n const segment: Segment = {\n id: `segment-${segmentIndex}`,\n url: resolveUrl(trimmed, baseUrl),\n duration: currentDuration,\n startTime: currentTime,\n };\n\n if (currentByteRange) {\n segment.byteRange = currentByteRange;\n previousByteRangeEnd = currentByteRange.end + 1;\n } else {\n previousByteRangeEnd = undefined;\n }\n\n segments.push(segment);\n currentTime += currentDuration;\n segmentIndex++;\n\n currentDuration = 0;\n currentByteRange = undefined;\n }\n }\n\n const totalDuration = currentTime;\n\n // Build initialization (VTT may not have init segment)\n const initialization =\n unresolved.type === 'text' && !initSegmentUrl\n ? undefined\n : initSegmentUrl\n ? { url: initSegmentUrl, ...(initSegmentByteRange ? { byteRange: initSegmentByteRange } : {}) }\n : { url: '' };\n\n // Container detection: fMP4 always carries an EXT-X-MAP init segment, so its\n // absence plus a recognized non-fMP4 segment extension (`.ts` → MPEG-TS,\n // `.aac` → raw ADTS AAC) marks a non-fMP4 rendition (high-precision — never\n // trips on fMP4, which mandates the map). Relabel from the fMP4 default\n // `video/mp4` / `audio/mp4` to the container MIME so capability probing prunes\n // it (these containers are currently treated as unplayable; see `canPlayTrack`).\n const detectedContainer = initSegmentUrl ? undefined : containerMimeFromSegment(segments[0]?.url);\n const mimeType = unresolved.type !== 'text' && detectedContainer ? detectedContainer : unresolved.mimeType;\n\n // Generic resolution: All type-specific fields already on unresolved track from P1\n // Just add parsed properties (startTime, duration, segments, initialization)\n return {\n ...unresolved,\n mimeType,\n startTime: 0,\n duration: totalDuration,\n segments,\n initialization,\n } as unknown as ResolveTrack;\n}\n"],"mappings":";;;;AAcA,MAAa,eAAe;;AAE5B,MAAa,eAAe;AAM5B,MAAM,8BAAsD;CAC1D,OAAO;CACP,QAAQ;AACV;;AAGA,MAAa,2BAA2B,IAAI,IAAI,OAAO,OAAO,2BAA2B,CAAC;;;;;AAM1F,SAAS,yBAAyB,KAA6C;CAC7E,IAAI,CAAC,KAAK,OAAO,KAAA;CACjB,IAAI;CACJ,IAAI;EACF,OAAO,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,YAAY;CAC3C,QAAQ;EACN,OAAO,IAAI,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM;CAC5C;CACA,MAAM,MAAM,KAAK,YAAY,GAAG;CAChC,OAAO,QAAQ,KAAK,KAAA,IAAY,4BAA4B,KAAK,MAAM,GAAG;AAC5E;;;;;;;;;;;AAuBA,SAAgB,mBACd,MACA,YACiB;CACjB,MAAM,QAAQ,KAAK,MAAM,OAAO;CAGhC,MAAM,UAAU,WAAW;CAG3B,MAAM,WAAsB,CAAC;CAC7B,IAAI;CACJ,IAAI;CAEJ,IAAI,kBAAkB;CACtB,IAAI;CACJ,IAAI,cAAc;CAClB,IAAI,eAAe;CACnB,IAAI;CAEJ,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,UAAU,KAAK,KAAK;EAE1B,IAAI,CAAC,WAAY,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,WAAW,MAAM,GACpE;EAGF,IACE,YAAY,aACZ,QAAQ,WAAW,iBAAiB,KACpC,QAAQ,WAAW,wBAAwB,KAC3C,QAAQ,WAAW,uBAAuB,KAC1C,QAAQ,WAAW,6BAA6B,GAEhD;EAIF,MAAM,WAAW,SAAS,SAAS,WAAW;EAC9C,IAAI,UAAU;GACZ,MAAM,MAAM,SAAS,IAAI,KAAK;GAC9B,IAAI,KAAK;IACP,iBAAiB,WAAW,KAAK,OAAO;IACxC,MAAM,eAAe,SAAS,IAAI,WAAW;IAC7C,IAAI,cACF,uBAAuB,eAAe,cAAc,CAAC,KAAK,KAAA;GAE9D;GACA;EACF;EAGA,IAAI,QAAQ,WAAW,UAAU,GAAG;GAClC,kBAAkB,oBAAoB,QAAQ,MAAM,CAAC,CAAC;GACtD;EACF;EAGA,IAAI,QAAQ,WAAW,mBAAmB,GAAG;GAC3C,mBAAmB,eAAe,QAAQ,MAAM,EAAE,GAAG,oBAAoB,KAAK,KAAA;GAC9E;EACF;EAEA,IAAI,YAAY,kBACd;EAIF,IAAI,CAAC,QAAQ,WAAW,GAAG,KAAK,kBAAkB,GAAG;GACnD,MAAM,UAAmB;IACvB,IAAI,WAAW;IACf,KAAK,WAAW,SAAS,OAAO;IAChC,UAAU;IACV,WAAW;GACb;GAEA,IAAI,kBAAkB;IACpB,QAAQ,YAAY;IACpB,uBAAuB,iBAAiB,MAAM;GAChD,OACE,uBAAuB,KAAA;GAGzB,SAAS,KAAK,OAAO;GACrB,eAAe;GACf;GAEA,kBAAkB;GAClB,mBAAmB,KAAA;EACrB;CACF;CAEA,MAAM,gBAAgB;CAGtB,MAAM,iBACJ,WAAW,SAAS,UAAU,CAAC,iBAC3B,KAAA,IACA,iBACE;EAAE,KAAK;EAAgB,GAAI,uBAAuB,EAAE,WAAW,qBAAqB,IAAI,CAAC;CAAG,IAC5F,EAAE,KAAK,GAAG;CAQlB,MAAM,oBAAoB,iBAAiB,KAAA,IAAY,yBAAyB,SAAS,EAAE,EAAE,GAAG;CAChG,MAAM,WAAW,WAAW,SAAS,UAAU,oBAAoB,oBAAoB,WAAW;CAIlG,OAAO;EACL,GAAG;EACH;EACA,WAAW;EACX,UAAU;EACV;EACA;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/media/hls/parse-multivariant.js b/dist/default/media/hls/parse-multivariant.js new file mode 100644 index 00000000..6c1756c1 --- /dev/null +++ b/dist/default/media/hls/parse-multivariant.js @@ -0,0 +1,223 @@ +import { matchTag, parseCodecs } from "./parse-attributes.js"; +import { resolveUrl } from "./resolve-url.js"; +import { generateId } from "@videojs/utils/string"; +//#region src/media/hls/parse-multivariant.ts +/** +* Parse HLS multivariant playlist into a Presentation. +* +* Returns Presentation with partially resolved tracks (no segment information). +* Tracks contain metadata from multivariant playlist (bandwidth, resolution, codecs) +* but segment information is added when media playlists are fetched. +* +* @param text - Raw playlist text content +* @param unresolved - Unresolved presentation (contains URL for base URL resolution) +* @returns Presentation with partially resolved tracks (duration is undefined) +*/ +function parseMultivariantPlaylist(text, unresolved) { + const baseUrl = unresolved.url; + const lines = text.split(/\r?\n/); + const streams = []; + const audioRenditions = []; + const subtitleRenditions = []; + let pendingStreamInfo = null; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") && !trimmed.startsWith("#EXT")) continue; + if (trimmed === "#EXTM3U" || trimmed.startsWith("#EXT-X-VERSION:") || trimmed.startsWith("#EXT-X-INDEPENDENT-SEGMENTS")) continue; + const mediaAttrs = matchTag(trimmed, "EXT-X-MEDIA"); + if (mediaAttrs) { + const type = mediaAttrs.get("TYPE"); + const groupId = mediaAttrs.get("GROUP-ID"); + const name = mediaAttrs.get("NAME"); + if (type === "AUDIO" && groupId && name) { + const uri = mediaAttrs.get("URI"); + audioRenditions.push({ + groupId, + name, + language: mediaAttrs.get("LANGUAGE"), + uri: uri ? resolveUrl(uri, baseUrl) : void 0, + default: mediaAttrs.getBool("DEFAULT"), + autoselect: mediaAttrs.getBool("AUTOSELECT"), + channels: mediaAttrs.getInt("CHANNELS") + }); + } + if (type === "SUBTITLES" && groupId && name) { + const uri = mediaAttrs.get("URI"); + if (uri) subtitleRenditions.push({ + groupId, + name, + language: mediaAttrs.get("LANGUAGE"), + uri: resolveUrl(uri, baseUrl), + default: mediaAttrs.getBool("DEFAULT"), + autoselect: mediaAttrs.getBool("AUTOSELECT"), + forced: mediaAttrs.getBool("FORCED") + }); + } + continue; + } + const streamInfAttrs = matchTag(trimmed, "EXT-X-STREAM-INF"); + if (streamInfAttrs) { + pendingStreamInfo = { + bandwidth: streamInfAttrs.getInt("BANDWIDTH", 0), + resolution: streamInfAttrs.getResolution("RESOLUTION"), + codecs: streamInfAttrs.get("CODECS"), + frameRate: streamInfAttrs.getFrameRate("FRAME-RATE"), + audioGroupId: streamInfAttrs.get("AUDIO") + }; + continue; + } + if (!trimmed.startsWith("#") && pendingStreamInfo) { + streams.push({ + ...pendingStreamInfo, + uri: resolveUrl(trimmed, baseUrl) + }); + pendingStreamInfo = null; + } + } + const videoStreams = []; + const audioOnlyStreams = []; + for (const stream of streams) { + if (!stream.codecs) { + videoStreams.push(stream); + continue; + } + const parsedCodecs = parseCodecs(stream.codecs); + if (stream.codecs.split(",").length === 1) if (parsedCodecs.audio && !parsedCodecs.video) audioOnlyStreams.push(stream); + else videoStreams.push(stream); + else videoStreams.push(stream); + } + const videoTracksByUrl = /* @__PURE__ */ new Map(); + for (const stream of videoStreams) { + const existing = videoTracksByUrl.get(stream.uri); + if (existing) { + if (stream.audioGroupId && !existing.audioGroupIds?.includes(stream.audioGroupId)) existing.audioGroupIds = [...existing.audioGroupIds ?? [], stream.audioGroupId]; + if (stream.bandwidth < existing.bandwidth) existing.bandwidth = stream.bandwidth; + continue; + } + const codecs = stream.codecs ? parseCodecs(stream.codecs) : void 0; + const track = { + type: "video", + id: generateId(), + url: stream.uri, + bandwidth: stream.bandwidth, + mimeType: "video/mp4", + codecs: [] + }; + if (stream.resolution?.width !== void 0) track.width = stream.resolution.width; + if (stream.resolution?.height !== void 0) track.height = stream.resolution.height; + if (codecs?.video) track.codecs = [codecs.video]; + if (stream.frameRate) track.frameRate = stream.frameRate; + if (stream.audioGroupId) track.audioGroupIds = [stream.audioGroupId]; + videoTracksByUrl.set(stream.uri, track); + } + const videoTracks = [...videoTracksByUrl.values()]; + const audioOnlyTracks = audioOnlyStreams.map((stream) => { + const codecs = stream.codecs ? parseCodecs(stream.codecs) : void 0; + return { + type: "audio", + id: generateId(), + url: stream.uri, + bandwidth: stream.bandwidth, + mimeType: "audio/mp4", + codecs: codecs?.audio ? [codecs.audio] : [], + groupId: stream.audioGroupId || "default", + name: "Default", + sampleRate: 48e3, + channels: 2 + }; + }); + const audioTracks = [...audioRenditions.map((rendition) => { + let audioCodecs; + for (const stream of streams) if (stream.audioGroupId === rendition.groupId && stream.codecs) { + const codecs = parseCodecs(stream.codecs); + if (codecs.audio) { + audioCodecs = [codecs.audio]; + break; + } + } + const track = { + type: "audio", + id: generateId(), + url: rendition.uri ?? "", + groupId: rendition.groupId, + name: rendition.name, + mimeType: "audio/mp4", + bandwidth: 0, + sampleRate: 48e3, + channels: rendition.channels ?? 2, + codecs: [] + }; + if (rendition.language) track.language = rendition.language; + if (audioCodecs) track.codecs = audioCodecs; + if (rendition.default) track.default = rendition.default; + if (rendition.autoselect) track.autoselect = rendition.autoselect; + return track; + }), ...audioOnlyTracks]; + const textTracks = subtitleRenditions.map((rendition) => { + const track = { + type: "text", + id: generateId(), + url: rendition.uri, + groupId: rendition.groupId, + label: rendition.name, + kind: "subtitles", + mimeType: "text/vtt", + bandwidth: 0 + }; + if (rendition.language) track.language = rendition.language; + if (rendition.default && rendition.autoselect) track.default = true; + if (rendition.autoselect) track.autoselect = rendition.autoselect; + if (rendition.forced) track.forced = rendition.forced; + return track; + }); + const selectionSets = []; + if (videoTracks.length > 0) { + const videoSwitchingSet = { + id: generateId(), + type: "video", + tracks: videoTracks + }; + const videoSelectionSet = { + id: generateId(), + type: "video", + switchingSets: [videoSwitchingSet] + }; + selectionSets.push(videoSelectionSet); + } + if (audioTracks.length > 0) { + const audioSwitchingSet = { + id: generateId(), + type: "audio", + tracks: audioTracks + }; + const audioSelectionSet = { + id: generateId(), + type: "audio", + switchingSets: [audioSwitchingSet] + }; + selectionSets.push(audioSelectionSet); + } + if (textTracks.length > 0) { + const textSwitchingSet = { + id: generateId(), + type: "text", + tracks: textTracks + }; + const textSelectionSet = { + id: generateId(), + type: "text", + switchingSets: [textSwitchingSet] + }; + selectionSets.push(textSelectionSet); + } + return { + id: generateId(), + url: unresolved.url, + startTime: 0, + selectionSets + }; +} +//#endregion +export { parseMultivariantPlaylist }; + +//# sourceMappingURL=parse-multivariant.js.map \ No newline at end of file diff --git a/dist/default/media/hls/parse-multivariant.js.map b/dist/default/media/hls/parse-multivariant.js.map new file mode 100644 index 00000000..82bc8e70 --- /dev/null +++ b/dist/default/media/hls/parse-multivariant.js.map @@ -0,0 +1 @@ +{"version":3,"file":"parse-multivariant.js","names":[],"sources":["../../../../src/media/hls/parse-multivariant.ts"],"sourcesContent":["import { generateId } from '@videojs/utils/string';\nimport type {\n AddressableObject,\n AudioSelectionSet,\n AudioSwitchingSet,\n FrameRate,\n PartiallyResolvedAudioTrack,\n PartiallyResolvedTextTrack,\n PartiallyResolvedVideoTrack,\n Presentation,\n SelectionSet,\n TextSelectionSet,\n TextSwitchingSet,\n VideoSelectionSet,\n VideoSwitchingSet,\n} from '../types';\nimport { matchTag, parseCodecs } from './parse-attributes';\nimport { resolveUrl } from './resolve-url';\n\n/**\n * Parse HLS multivariant playlist into a Presentation.\n *\n * Returns Presentation with partially resolved tracks (no segment information).\n * Tracks contain metadata from multivariant playlist (bandwidth, resolution, codecs)\n * but segment information is added when media playlists are fetched.\n *\n * @param text - Raw playlist text content\n * @param unresolved - Unresolved presentation (contains URL for base URL resolution)\n * @returns Presentation with partially resolved tracks (duration is undefined)\n */\nexport function parseMultivariantPlaylist(text: string, unresolved: AddressableObject): Presentation {\n const baseUrl = unresolved.url;\n const lines = text.split(/\\r?\\n/);\n\n // Intermediate parsing structures\n interface StreamInfo {\n uri: string;\n bandwidth: number;\n resolution?: { width: number; height: number } | undefined;\n codecs?: string | undefined;\n frameRate?: FrameRate | undefined;\n audioGroupId?: string | undefined;\n }\n\n interface AudioRenditionInfo {\n groupId: string;\n name: string;\n language?: string | undefined;\n uri?: string | undefined;\n default?: boolean | undefined;\n autoselect?: boolean | undefined;\n channels?: number | undefined;\n }\n\n interface SubtitleRenditionInfo {\n groupId: string;\n name: string;\n language?: string | undefined;\n uri: string;\n default?: boolean | undefined;\n autoselect?: boolean | undefined;\n forced?: boolean | undefined;\n }\n\n const streams: StreamInfo[] = [];\n const audioRenditions: AudioRenditionInfo[] = [];\n const subtitleRenditions: SubtitleRenditionInfo[] = [];\n\n // State for STREAM-INF parsing (URI follows on next line)\n let pendingStreamInfo: Omit | null = null;\n\n for (const line of lines) {\n const trimmed = line.trim();\n\n // Skip empty lines and comments\n if (!trimmed || (trimmed.startsWith('#') && !trimmed.startsWith('#EXT'))) {\n continue;\n }\n\n // Skip tags not used in Presentation model\n if (\n trimmed === '#EXTM3U' ||\n trimmed.startsWith('#EXT-X-VERSION:') ||\n trimmed.startsWith('#EXT-X-INDEPENDENT-SEGMENTS')\n ) {\n continue;\n }\n\n // #EXT-X-MEDIA:TYPE=AUDIO/SUBTITLES\n const mediaAttrs = matchTag(trimmed, 'EXT-X-MEDIA');\n if (mediaAttrs) {\n const type = mediaAttrs.get('TYPE');\n const groupId = mediaAttrs.get('GROUP-ID');\n const name = mediaAttrs.get('NAME');\n\n if (type === 'AUDIO' && groupId && name) {\n const uri = mediaAttrs.get('URI');\n audioRenditions.push({\n groupId,\n name,\n language: mediaAttrs.get('LANGUAGE'),\n uri: uri ? resolveUrl(uri, baseUrl) : undefined,\n default: mediaAttrs.getBool('DEFAULT'),\n autoselect: mediaAttrs.getBool('AUTOSELECT'),\n // CHANNELS is a quoted string whose first parameter is the channel\n // count (\"6\", or \"16/JOC\" for spatial audio); getInt reads the\n // leading integer.\n channels: mediaAttrs.getInt('CHANNELS'),\n });\n }\n\n if (type === 'SUBTITLES' && groupId && name) {\n const uri = mediaAttrs.get('URI');\n // URI is required for subtitle tracks\n if (uri) {\n subtitleRenditions.push({\n groupId,\n name,\n language: mediaAttrs.get('LANGUAGE'),\n uri: resolveUrl(uri, baseUrl),\n default: mediaAttrs.getBool('DEFAULT'),\n autoselect: mediaAttrs.getBool('AUTOSELECT'),\n forced: mediaAttrs.getBool('FORCED'),\n });\n }\n }\n continue;\n }\n\n // #EXT-X-STREAM-INF:BANDWIDTH=...\n const streamInfAttrs = matchTag(trimmed, 'EXT-X-STREAM-INF');\n if (streamInfAttrs) {\n pendingStreamInfo = {\n bandwidth: streamInfAttrs.getInt('BANDWIDTH', 0)!,\n resolution: streamInfAttrs.getResolution('RESOLUTION'),\n codecs: streamInfAttrs.get('CODECS'),\n frameRate: streamInfAttrs.getFrameRate('FRAME-RATE'),\n audioGroupId: streamInfAttrs.get('AUDIO'),\n };\n continue;\n }\n\n // URI line following STREAM-INF\n if (!trimmed.startsWith('#') && pendingStreamInfo) {\n streams.push({\n ...pendingStreamInfo,\n uri: resolveUrl(trimmed, baseUrl),\n });\n pendingStreamInfo = null;\n }\n }\n\n // Separate streams into video and audio based on codecs\n // If CODECS has single codec, use parseCodecs to determine type\n const videoStreams: typeof streams = [];\n const audioOnlyStreams: typeof streams = [];\n\n for (const stream of streams) {\n if (!stream.codecs) {\n // No codecs - assume video (default behavior)\n videoStreams.push(stream);\n continue;\n }\n\n const parsedCodecs = parseCodecs(stream.codecs);\n const codecCount = stream.codecs.split(',').length;\n\n // Single codec - determine type from parseCodecs result\n if (codecCount === 1) {\n if (parsedCodecs.audio && !parsedCodecs.video) {\n // Audio-only stream\n audioOnlyStreams.push(stream);\n } else {\n // Video stream (or unknown - default to video)\n videoStreams.push(stream);\n }\n } else {\n // Multiple codecs - video stream with muxed audio\n videoStreams.push(stream);\n }\n }\n\n // Build PartiallyResolvedVideoTracks from video streams, de-duplicating the\n // HLS cross-product: one video rendition is listed across several\n // `EXT-X-STREAM-INF` entries — one per audio group it can pair with, all\n // sharing the same media-playlist URI. Collapse them to one track per URI,\n // accumulating every advertised audio group. (Redundant-stream renditions\n // live at *distinct* per-CDN URIs, so they stay separate — only the same-URI\n // cross-product merges.)\n const videoTracksByUrl = new Map();\n for (const stream of videoStreams) {\n const existing = videoTracksByUrl.get(stream.uri);\n if (existing) {\n if (stream.audioGroupId && !existing.audioGroupIds?.includes(stream.audioGroupId)) {\n existing.audioGroupIds = [...(existing.audioGroupIds ?? []), stream.audioGroupId];\n }\n // BANDWIDTH is video + audio combined; the duplicates differ only in the\n // paired audio. Keep the lowest as the closest proxy to video-only, which\n // is what ABR should rank on.\n if (stream.bandwidth < existing.bandwidth) {\n existing.bandwidth = stream.bandwidth;\n }\n continue;\n }\n\n const codecs = stream.codecs ? parseCodecs(stream.codecs) : undefined;\n\n const track: PartiallyResolvedVideoTrack = {\n type: 'video' as const,\n id: generateId(),\n url: stream.uri,\n bandwidth: stream.bandwidth,\n // Type-specific defaults (CMAF video)\n mimeType: 'video/mp4',\n codecs: [],\n };\n\n if (stream.resolution?.width !== undefined) {\n track.width = stream.resolution.width;\n }\n if (stream.resolution?.height !== undefined) {\n track.height = stream.resolution.height;\n }\n if (codecs?.video) {\n track.codecs = [codecs.video];\n }\n if (stream.frameRate) {\n track.frameRate = stream.frameRate;\n }\n if (stream.audioGroupId) {\n track.audioGroupIds = [stream.audioGroupId];\n }\n\n videoTracksByUrl.set(stream.uri, track);\n }\n const videoTracks: PartiallyResolvedVideoTrack[] = [...videoTracksByUrl.values()];\n\n // Build PartiallyResolvedAudioTracks from audio-only streams\n const audioOnlyTracks: PartiallyResolvedAudioTrack[] = audioOnlyStreams.map((stream) => {\n const codecs = stream.codecs ? parseCodecs(stream.codecs) : undefined;\n\n const track: PartiallyResolvedAudioTrack = {\n type: 'audio' as const,\n id: generateId(),\n url: stream.uri,\n bandwidth: stream.bandwidth,\n mimeType: 'audio/mp4',\n codecs: codecs?.audio ? [codecs.audio] : [],\n groupId: stream.audioGroupId || 'default',\n name: 'Default',\n sampleRate: 48000, // Default - will be in media playlist if available\n channels: 2, // Default - will be in media playlist if available\n };\n\n return track;\n });\n\n // Build PartiallyResolvedAudioTracks from audio renditions (EXT-X-MEDIA)\n // Extract audio codecs from referencing streams\n const audioRenditionTracks: PartiallyResolvedAudioTrack[] = audioRenditions.map((rendition) => {\n let audioCodecs: string[] | undefined;\n for (const stream of streams) {\n if (stream.audioGroupId === rendition.groupId && stream.codecs) {\n const codecs = parseCodecs(stream.codecs);\n if (codecs.audio) {\n audioCodecs = [codecs.audio];\n break;\n }\n }\n }\n\n const track: PartiallyResolvedAudioTrack = {\n type: 'audio' as const,\n id: generateId(),\n url: rendition.uri ?? '',\n groupId: rendition.groupId,\n name: rendition.name,\n // Type-specific defaults (CMAF audio)\n mimeType: 'audio/mp4',\n bandwidth: 0, // Not available in multivariant for demuxed audio\n sampleRate: 48000, // CMAF default\n channels: rendition.channels ?? 2, // From EXT-X-MEDIA CHANNELS; stereo default\n codecs: [],\n };\n\n if (rendition.language) {\n track.language = rendition.language;\n }\n if (audioCodecs) {\n track.codecs = audioCodecs;\n }\n if (rendition.default) {\n track.default = rendition.default;\n }\n if (rendition.autoselect) {\n track.autoselect = rendition.autoselect;\n }\n\n return track;\n });\n\n // Combine audio tracks from both EXT-X-MEDIA renditions and audio-only STREAM-INF\n const audioTracks = [...audioRenditionTracks, ...audioOnlyTracks];\n\n // Build PartiallyResolvedTextTracks from subtitle renditions\n const textTracks: PartiallyResolvedTextTrack[] = subtitleRenditions.map((rendition) => {\n const track: PartiallyResolvedTextTrack = {\n type: 'text' as const,\n id: generateId(),\n url: rendition.uri,\n groupId: rendition.groupId,\n label: rendition.name,\n kind: 'subtitles' as const,\n // Type-specific defaults (VTT)\n mimeType: 'text/vtt',\n bandwidth: 0, // Text tracks don't consume bandwidth\n };\n\n if (rendition.language) {\n track.language = rendition.language;\n }\n // Match hls.js/http-streaming: only set default=true when BOTH DEFAULT=YES AND AUTOSELECT=YES\n if (rendition.default && rendition.autoselect) {\n track.default = true;\n }\n if (rendition.autoselect) {\n track.autoselect = rendition.autoselect;\n }\n if (rendition.forced) {\n track.forced = rendition.forced;\n }\n\n return track;\n });\n\n // Build selection sets\n const selectionSets: SelectionSet[] = [];\n\n if (videoTracks.length > 0) {\n const videoSwitchingSet: VideoSwitchingSet = {\n id: generateId(),\n type: 'video',\n tracks: videoTracks,\n };\n\n const videoSelectionSet: VideoSelectionSet = {\n id: generateId(),\n type: 'video',\n switchingSets: [videoSwitchingSet],\n };\n\n selectionSets.push(videoSelectionSet);\n }\n\n if (audioTracks.length > 0) {\n const audioSwitchingSet: AudioSwitchingSet = {\n id: generateId(),\n type: 'audio',\n tracks: audioTracks,\n };\n\n const audioSelectionSet: AudioSelectionSet = {\n id: generateId(),\n type: 'audio',\n switchingSets: [audioSwitchingSet],\n };\n\n selectionSets.push(audioSelectionSet);\n }\n\n if (textTracks.length > 0) {\n const textSwitchingSet: TextSwitchingSet = {\n id: generateId(),\n type: 'text',\n tracks: textTracks,\n };\n\n const textSelectionSet: TextSelectionSet = {\n id: generateId(),\n type: 'text',\n switchingSets: [textSwitchingSet],\n };\n\n selectionSets.push(textSelectionSet);\n }\n\n // Build presentation (duration is undefined until tracks are resolved)\n return {\n id: generateId(),\n url: unresolved.url,\n startTime: 0,\n // duration: undefined, // Won't be known until after at least one media playlist is fetched + parsed\n selectionSets,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AA8BA,SAAgB,0BAA0B,MAAc,YAA6C;CACnG,MAAM,UAAU,WAAW;CAC3B,MAAM,QAAQ,KAAK,MAAM,OAAO;CAgChC,MAAM,UAAwB,CAAC;CAC/B,MAAM,kBAAwC,CAAC;CAC/C,MAAM,qBAA8C,CAAC;CAGrD,IAAI,oBAAoD;CAExD,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,CAAC,WAAY,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,WAAW,MAAM,GACpE;EAIF,IACE,YAAY,aACZ,QAAQ,WAAW,iBAAiB,KACpC,QAAQ,WAAW,6BAA6B,GAEhD;EAIF,MAAM,aAAa,SAAS,SAAS,aAAa;EAClD,IAAI,YAAY;GACd,MAAM,OAAO,WAAW,IAAI,MAAM;GAClC,MAAM,UAAU,WAAW,IAAI,UAAU;GACzC,MAAM,OAAO,WAAW,IAAI,MAAM;GAElC,IAAI,SAAS,WAAW,WAAW,MAAM;IACvC,MAAM,MAAM,WAAW,IAAI,KAAK;IAChC,gBAAgB,KAAK;KACnB;KACA;KACA,UAAU,WAAW,IAAI,UAAU;KACnC,KAAK,MAAM,WAAW,KAAK,OAAO,IAAI,KAAA;KACtC,SAAS,WAAW,QAAQ,SAAS;KACrC,YAAY,WAAW,QAAQ,YAAY;KAI3C,UAAU,WAAW,OAAO,UAAU;IACxC,CAAC;GACH;GAEA,IAAI,SAAS,eAAe,WAAW,MAAM;IAC3C,MAAM,MAAM,WAAW,IAAI,KAAK;IAEhC,IAAI,KACF,mBAAmB,KAAK;KACtB;KACA;KACA,UAAU,WAAW,IAAI,UAAU;KACnC,KAAK,WAAW,KAAK,OAAO;KAC5B,SAAS,WAAW,QAAQ,SAAS;KACrC,YAAY,WAAW,QAAQ,YAAY;KAC3C,QAAQ,WAAW,QAAQ,QAAQ;IACrC,CAAC;GAEL;GACA;EACF;EAGA,MAAM,iBAAiB,SAAS,SAAS,kBAAkB;EAC3D,IAAI,gBAAgB;GAClB,oBAAoB;IAClB,WAAW,eAAe,OAAO,aAAa,CAAC;IAC/C,YAAY,eAAe,cAAc,YAAY;IACrD,QAAQ,eAAe,IAAI,QAAQ;IACnC,WAAW,eAAe,aAAa,YAAY;IACnD,cAAc,eAAe,IAAI,OAAO;GAC1C;GACA;EACF;EAGA,IAAI,CAAC,QAAQ,WAAW,GAAG,KAAK,mBAAmB;GACjD,QAAQ,KAAK;IACX,GAAG;IACH,KAAK,WAAW,SAAS,OAAO;GAClC,CAAC;GACD,oBAAoB;EACtB;CACF;CAIA,MAAM,eAA+B,CAAC;CACtC,MAAM,mBAAmC,CAAC;CAE1C,KAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,CAAC,OAAO,QAAQ;GAElB,aAAa,KAAK,MAAM;GACxB;EACF;EAEA,MAAM,eAAe,YAAY,OAAO,MAAM;EAI9C,IAHmB,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,WAGzB,GACjB,IAAI,aAAa,SAAS,CAAC,aAAa,OAEtC,iBAAiB,KAAK,MAAM;OAG5B,aAAa,KAAK,MAAM;OAI1B,aAAa,KAAK,MAAM;CAE5B;CASA,MAAM,mCAAmB,IAAI,IAAyC;CACtE,KAAK,MAAM,UAAU,cAAc;EACjC,MAAM,WAAW,iBAAiB,IAAI,OAAO,GAAG;EAChD,IAAI,UAAU;GACZ,IAAI,OAAO,gBAAgB,CAAC,SAAS,eAAe,SAAS,OAAO,YAAY,GAC9E,SAAS,gBAAgB,CAAC,GAAI,SAAS,iBAAiB,CAAC,GAAI,OAAO,YAAY;GAKlF,IAAI,OAAO,YAAY,SAAS,WAC9B,SAAS,YAAY,OAAO;GAE9B;EACF;EAEA,MAAM,SAAS,OAAO,SAAS,YAAY,OAAO,MAAM,IAAI,KAAA;EAE5D,MAAM,QAAqC;GACzC,MAAM;GACN,IAAI,WAAW;GACf,KAAK,OAAO;GACZ,WAAW,OAAO;GAElB,UAAU;GACV,QAAQ,CAAC;EACX;EAEA,IAAI,OAAO,YAAY,UAAU,KAAA,GAC/B,MAAM,QAAQ,OAAO,WAAW;EAElC,IAAI,OAAO,YAAY,WAAW,KAAA,GAChC,MAAM,SAAS,OAAO,WAAW;EAEnC,IAAI,QAAQ,OACV,MAAM,SAAS,CAAC,OAAO,KAAK;EAE9B,IAAI,OAAO,WACT,MAAM,YAAY,OAAO;EAE3B,IAAI,OAAO,cACT,MAAM,gBAAgB,CAAC,OAAO,YAAY;EAG5C,iBAAiB,IAAI,OAAO,KAAK,KAAK;CACxC;CACA,MAAM,cAA6C,CAAC,GAAG,iBAAiB,OAAO,CAAC;CAGhF,MAAM,kBAAiD,iBAAiB,KAAK,WAAW;EACtF,MAAM,SAAS,OAAO,SAAS,YAAY,OAAO,MAAM,IAAI,KAAA;EAe5D,OAAO;GAZL,MAAM;GACN,IAAI,WAAW;GACf,KAAK,OAAO;GACZ,WAAW,OAAO;GAClB,UAAU;GACV,QAAQ,QAAQ,QAAQ,CAAC,OAAO,KAAK,IAAI,CAAC;GAC1C,SAAS,OAAO,gBAAgB;GAChC,MAAM;GACN,YAAY;GACZ,UAAU;EAGD;CACb,CAAC;CA+CD,MAAM,cAAc,CAAC,GA3CuC,gBAAgB,KAAK,cAAc;EAC7F,IAAI;EACJ,KAAK,MAAM,UAAU,SACnB,IAAI,OAAO,iBAAiB,UAAU,WAAW,OAAO,QAAQ;GAC9D,MAAM,SAAS,YAAY,OAAO,MAAM;GACxC,IAAI,OAAO,OAAO;IAChB,cAAc,CAAC,OAAO,KAAK;IAC3B;GACF;EACF;EAGF,MAAM,QAAqC;GACzC,MAAM;GACN,IAAI,WAAW;GACf,KAAK,UAAU,OAAO;GACtB,SAAS,UAAU;GACnB,MAAM,UAAU;GAEhB,UAAU;GACV,WAAW;GACX,YAAY;GACZ,UAAU,UAAU,YAAY;GAChC,QAAQ,CAAC;EACX;EAEA,IAAI,UAAU,UACZ,MAAM,WAAW,UAAU;EAE7B,IAAI,aACF,MAAM,SAAS;EAEjB,IAAI,UAAU,SACZ,MAAM,UAAU,UAAU;EAE5B,IAAI,UAAU,YACZ,MAAM,aAAa,UAAU;EAG/B,OAAO;CACT,CAG2C,GAAG,GAAG,eAAe;CAGhE,MAAM,aAA2C,mBAAmB,KAAK,cAAc;EACrF,MAAM,QAAoC;GACxC,MAAM;GACN,IAAI,WAAW;GACf,KAAK,UAAU;GACf,SAAS,UAAU;GACnB,OAAO,UAAU;GACjB,MAAM;GAEN,UAAU;GACV,WAAW;EACb;EAEA,IAAI,UAAU,UACZ,MAAM,WAAW,UAAU;EAG7B,IAAI,UAAU,WAAW,UAAU,YACjC,MAAM,UAAU;EAElB,IAAI,UAAU,YACZ,MAAM,aAAa,UAAU;EAE/B,IAAI,UAAU,QACZ,MAAM,SAAS,UAAU;EAG3B,OAAO;CACT,CAAC;CAGD,MAAM,gBAAgC,CAAC;CAEvC,IAAI,YAAY,SAAS,GAAG;EAC1B,MAAM,oBAAuC;GAC3C,IAAI,WAAW;GACf,MAAM;GACN,QAAQ;EACV;EAEA,MAAM,oBAAuC;GAC3C,IAAI,WAAW;GACf,MAAM;GACN,eAAe,CAAC,iBAAiB;EACnC;EAEA,cAAc,KAAK,iBAAiB;CACtC;CAEA,IAAI,YAAY,SAAS,GAAG;EAC1B,MAAM,oBAAuC;GAC3C,IAAI,WAAW;GACf,MAAM;GACN,QAAQ;EACV;EAEA,MAAM,oBAAuC;GAC3C,IAAI,WAAW;GACf,MAAM;GACN,eAAe,CAAC,iBAAiB;EACnC;EAEA,cAAc,KAAK,iBAAiB;CACtC;CAEA,IAAI,WAAW,SAAS,GAAG;EACzB,MAAM,mBAAqC;GACzC,IAAI,WAAW;GACf,MAAM;GACN,QAAQ;EACV;EAEA,MAAM,mBAAqC;GACzC,IAAI,WAAW;GACf,MAAM;GACN,eAAe,CAAC,gBAAgB;EAClC;EAEA,cAAc,KAAK,gBAAgB;CACrC;CAGA,OAAO;EACL,IAAI,WAAW;EACf,KAAK,WAAW;EAChB,WAAW;EAEX;CACF;AACF"} \ No newline at end of file diff --git a/dist/default/media/hls/resolve-url.js b/dist/default/media/hls/resolve-url.js new file mode 100644 index 00000000..23a28704 --- /dev/null +++ b/dist/default/media/hls/resolve-url.js @@ -0,0 +1,11 @@ +//#region src/media/hls/resolve-url.ts +/** +* Resolve a potentially relative URL against a base URL using native URL API. +*/ +function resolveUrl(url, baseUrl) { + return new URL(url, baseUrl).href; +} +//#endregion +export { resolveUrl }; + +//# sourceMappingURL=resolve-url.js.map \ No newline at end of file diff --git a/dist/default/media/hls/resolve-url.js.map b/dist/default/media/hls/resolve-url.js.map new file mode 100644 index 00000000..370d7635 --- /dev/null +++ b/dist/default/media/hls/resolve-url.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resolve-url.js","names":[],"sources":["../../../../src/media/hls/resolve-url.ts"],"sourcesContent":["/**\n * Resolve a potentially relative URL against a base URL using native URL API.\n */\nexport function resolveUrl(url: string, baseUrl: string): string {\n return new URL(url, baseUrl).href;\n}\n"],"mappings":";;;;AAGA,SAAgB,WAAW,KAAa,SAAyB;CAC/D,OAAO,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC;AAC/B"} \ No newline at end of file diff --git a/dist/default/media/media-tracks/media-tracks.js b/dist/default/media/media-tracks/media-tracks.js new file mode 100644 index 00000000..83cd48c1 --- /dev/null +++ b/dist/default/media/media-tracks/media-tracks.js @@ -0,0 +1,101 @@ +import { findTrackById, getTracksByType } from "../utils/tracks.js"; +//#region src/media/media-tracks/media-tracks.ts +/** +* The distinct video tracks of a presentation, deduped by {@link VideoDedupeKey} (first occurrence wins). +* +* Returns `[]` when the presentation is unresolved or has no video tracks. +*/ +function dedupedVideoTracks(presentation) { + if (!presentation) return []; + return dedupe({ + tracks: getTracksByType(presentation, "video"), + keyFn: toUserVideoTrackSelection + }); +} +/** +* The distinct audio tracks of a presentation, deduped by `language` + `name` +* (first occurrence wins). Returns `[]` when the presentation is unresolved or has no audio tracks. +*/ +function dedupedAudioTracks(presentation) { + if (!presentation) return []; + return dedupe({ + tracks: getTracksByType(presentation, "audio"), + keyFn: toUserAudioTrackSelection + }); +} +/** +* Find a video track by id, searching the same candidate set the engine resolves +* against ({@link dedupedVideoTracks}'s pre-dedupe source). Returns `undefined` +* when absent. Maps the engine's resolved `selectedVideoTrackId` back to its +* properties for `active` reflection — the resolved id may be a per-CDN copy that +* isn't the representative {@link dedupedVideoTracks} kept. +*/ +function findVideoTrackById(presentation, id) { + if (!presentation || !id) return void 0; + const track = findTrackById(presentation, id); + return track?.type === "video" ? track : void 0; +} +/** Audio counterpart of {@link findVideoTrackById}, for `enabled` reflection. */ +function findAudioTrackById(presentation, id) { + if (!presentation || !id) return void 0; + const track = findTrackById(presentation, id); + return track?.type === "audio" ? track : void 0; +} +/** +* Shallow-equal two key objects by their own properties. Both come from the same +* key builder, so they carry the same keys — a one-directional scan suffices. +*/ +function sameKey(a, b) { + for (const attr in a) if (a[attr] !== b[attr]) return false; + return true; +} +/** +* Dedupe tracks by a key function, keeping the first occurrence of each key. +* Keys are compared field-by-field ({@link sameKey}). +*/ +function dedupe({ tracks, keyFn }) { + const seen = []; + const kept = []; + for (const track of tracks) { + const key = keyFn(track); + if (!key || seen.some((other) => sameKey(other, key))) continue; + seen.push(key); + kept.push(track); + } + return kept; +} +/** +* Build a partial video track that can be used as `userVideoTrackSelection`. +*/ +function toUserVideoTrackSelection(rendition) { + return rendition ? { + width: rendition.width, + height: rendition.height, + bandwidth: rendition.bandwidth + } : void 0; +} +/** +* Build a partial audio track that can be used as a `userAudioTrackSelection`. +*/ +function toUserAudioTrackSelection(track) { + return track ? { + language: track.language, + name: track.name + } : void 0; +} +/** Whether two video tracks are the same by dedupe key */ +function isSameVideoTrack(a, b) { + return !!b && a.width === b.width && a.height === b.height && a.bandwidth === b.bandwidth; +} +/** Whether two audio tracks are the same by dedupe key */ +function isSameAudioTrack(a, b) { + return !!b && (a.language ?? "") === (b.language ?? "") && a.name === b.name; +} +/** Collapse a rational frame rate (numerator/denominator) to frames per second. */ +const frameRateToNumber = (frameRate) => { + return frameRate.frameRateNumerator / (frameRate.frameRateDenominator ?? 1); +}; +//#endregion +export { dedupedAudioTracks, dedupedVideoTracks, findAudioTrackById, findVideoTrackById, frameRateToNumber, isSameAudioTrack, isSameVideoTrack, toUserAudioTrackSelection, toUserVideoTrackSelection }; + +//# sourceMappingURL=media-tracks.js.map \ No newline at end of file diff --git a/dist/default/media/media-tracks/media-tracks.js.map b/dist/default/media/media-tracks/media-tracks.js.map new file mode 100644 index 00000000..ff7ec49d --- /dev/null +++ b/dist/default/media/media-tracks/media-tracks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"media-tracks.js","names":[],"sources":["../../../../src/media/media-tracks/media-tracks.ts"],"sourcesContent":["/**\n * Media-track translation utilities.\n *\n * Pure, DOM-free transforms from SPF's CMAF-HAM track model onto the deduped\n * lists a media-element adapter exposes (video renditions, audio tracks), plus\n * the selection-criteria builders that turn a chosen rendition/track back into a\n * `user*TrackSelection` partial the engine's track-switching reads.\n *\n * These return SPF *model vocabulary* (`bandwidth`, `codecs: string[]`,\n * `frameRate` as a rational `FrameRate`); the consuming adapter owns the\n * mapping (e.g. for DOM `bandwidth` -> `bitrate`, `codecs.join(',')` -> `codec`,\n * `name` -> `label`).\n *\n * Deduplication is by *properties*, never URL, so a multi-CDN source that lists\n * the same rendition on several hosts collapses to one entry: video renditions\n * by `width` + `height` + `bandwidth`, audio tracks by `language` + `name`.\n * The selection builders emit those same properties as the match criteria, so\n * selecting a collapsed entry re-selects, for example, every underlying per-CDN track.\n */\n\nimport type { AudioTrack, FrameRate, MaybeResolvedPresentation, VideoTrack } from '../types';\nimport { findTrackById, getTracksByType } from '../utils/tracks';\n\nexport type { AudioTrack, VideoTrack };\n\n/** Properties that identify a distinct video rendition (multi-CDN copies share them). */\nexport interface VideoDedupeKey {\n width?: VideoTrack['width'];\n height?: VideoTrack['height'];\n bandwidth?: VideoTrack['bandwidth'];\n}\n\n/** Properties that identify a distinct audio track (multi-CDN copies share them). */\nexport interface AudioDedupeKey {\n language?: AudioTrack['language'];\n name?: AudioTrack['name'];\n}\n\n/**\n * The distinct video tracks of a presentation, deduped by {@link VideoDedupeKey} (first occurrence wins).\n *\n * Returns `[]` when the presentation is unresolved or has no video tracks.\n */\nexport function dedupedVideoTracks(presentation: MaybeResolvedPresentation | undefined): VideoTrack[] {\n if (!presentation) return [];\n\n return dedupe({\n tracks: getTracksByType(presentation, 'video') as readonly VideoTrack[],\n keyFn: toUserVideoTrackSelection,\n });\n}\n\n/**\n * The distinct audio tracks of a presentation, deduped by `language` + `name`\n * (first occurrence wins). Returns `[]` when the presentation is unresolved or has no audio tracks.\n */\nexport function dedupedAudioTracks(presentation: MaybeResolvedPresentation | undefined): AudioTrack[] {\n if (!presentation) return [];\n\n return dedupe({\n tracks: getTracksByType(presentation, 'audio') as readonly AudioTrack[],\n keyFn: toUserAudioTrackSelection,\n });\n}\n\n/**\n * Find a video track by id, searching the same candidate set the engine resolves\n * against ({@link dedupedVideoTracks}'s pre-dedupe source). Returns `undefined`\n * when absent. Maps the engine's resolved `selectedVideoTrackId` back to its\n * properties for `active` reflection — the resolved id may be a per-CDN copy that\n * isn't the representative {@link dedupedVideoTracks} kept.\n */\nexport function findVideoTrackById(\n presentation: MaybeResolvedPresentation | undefined,\n id: string | undefined\n): VideoTrack | undefined {\n if (!presentation || !id) return undefined;\n const track = findTrackById(presentation, id);\n return track?.type === 'video' ? (track as VideoTrack) : undefined;\n}\n\n/** Audio counterpart of {@link findVideoTrackById}, for `enabled` reflection. */\nexport function findAudioTrackById(\n presentation: MaybeResolvedPresentation | undefined,\n id: string | undefined\n): AudioTrack | undefined {\n if (!presentation || !id) return undefined;\n const track = findTrackById(presentation, id);\n return track?.type === 'audio' ? (track as AudioTrack) : undefined;\n}\n\n/**\n * Shallow-equal two key objects by their own properties. Both come from the same\n * key builder, so they carry the same keys — a one-directional scan suffices.\n */\nfunction sameKey(a: K, b: K): boolean {\n for (const attr in a) {\n if (a[attr] !== b[attr]) return false;\n }\n return true;\n}\n\n/**\n * Dedupe tracks by a key function, keeping the first occurrence of each key.\n * Keys are compared field-by-field ({@link sameKey}).\n */\nfunction dedupe({\n tracks,\n keyFn,\n}: {\n tracks: readonly T[];\n keyFn: (track: T) => K | undefined;\n}): T[] {\n const seen: K[] = [];\n const kept: T[] = [];\n for (const track of tracks) {\n const key = keyFn(track);\n if (!key || seen.some((other) => sameKey(other, key))) continue;\n seen.push(key);\n kept.push(track);\n }\n\n return kept;\n}\n\n/**\n * Build a partial video track that can be used as `userVideoTrackSelection`.\n */\nexport function toUserVideoTrackSelection(rendition?: T): Partial | undefined {\n return rendition ? { width: rendition.width, height: rendition.height, bandwidth: rendition.bandwidth } : undefined;\n}\n\n/**\n * Build a partial audio track that can be used as a `userAudioTrackSelection`.\n */\nexport function toUserAudioTrackSelection(track?: T): Partial | undefined {\n return track ? { language: track.language, name: track.name } : undefined;\n}\n\n/** Whether two video tracks are the same by dedupe key */\nexport function isSameVideoTrack(a: VideoDedupeKey, b: VideoDedupeKey | undefined): boolean {\n return !!b && a.width === b.width && a.height === b.height && a.bandwidth === b.bandwidth;\n}\n\n/** Whether two audio tracks are the same by dedupe key */\nexport function isSameAudioTrack(a: AudioDedupeKey, b: AudioDedupeKey | undefined): boolean {\n return !!b && (a.language ?? '') === (b.language ?? '') && a.name === b.name;\n}\n\n/** Collapse a rational frame rate (numerator/denominator) to frames per second. */\nexport const frameRateToNumber = (frameRate: FrameRate) => {\n return frameRate.frameRateNumerator / (frameRate.frameRateDenominator ?? 1);\n};\n"],"mappings":";;;;;;;AA2CA,SAAgB,mBAAmB,cAAmE;CACpG,IAAI,CAAC,cAAc,OAAO,CAAC;CAE3B,OAAO,OAAO;EACZ,QAAQ,gBAAgB,cAAc,OAAO;EAC7C,OAAO;CACT,CAAC;AACH;;;;;AAMA,SAAgB,mBAAmB,cAAmE;CACpG,IAAI,CAAC,cAAc,OAAO,CAAC;CAE3B,OAAO,OAAO;EACZ,QAAQ,gBAAgB,cAAc,OAAO;EAC7C,OAAO;CACT,CAAC;AACH;;;;;;;;AASA,SAAgB,mBACd,cACA,IACwB;CACxB,IAAI,CAAC,gBAAgB,CAAC,IAAI,OAAO,KAAA;CACjC,MAAM,QAAQ,cAAc,cAAc,EAAE;CAC5C,OAAO,OAAO,SAAS,UAAW,QAAuB,KAAA;AAC3D;;AAGA,SAAgB,mBACd,cACA,IACwB;CACxB,IAAI,CAAC,gBAAgB,CAAC,IAAI,OAAO,KAAA;CACjC,MAAM,QAAQ,cAAc,cAAc,EAAE;CAC5C,OAAO,OAAO,SAAS,UAAW,QAAuB,KAAA;AAC3D;;;;;AAMA,SAAS,QAA0B,GAAM,GAAe;CACtD,KAAK,MAAM,QAAQ,GACjB,IAAI,EAAE,UAAU,EAAE,OAAO,OAAO;CAElC,OAAO;AACT;;;;;AAMA,SAAS,OAA4B,EACnC,QACA,SAIM;CACN,MAAM,OAAY,CAAC;CACnB,MAAM,OAAY,CAAC;CACnB,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,MAAM,MAAM,KAAK;EACvB,IAAI,CAAC,OAAO,KAAK,MAAM,UAAU,QAAQ,OAAO,GAAG,CAAC,GAAG;EACvD,KAAK,KAAK,GAAG;EACb,KAAK,KAAK,KAAK;CACjB;CAEA,OAAO;AACT;;;;AAKA,SAAgB,0BAAoD,WAAgD;CAClH,OAAO,YAAY;EAAE,OAAO,UAAU;EAAO,QAAQ,UAAU;EAAQ,WAAW,UAAU;CAAU,IAAI,KAAA;AAC5G;;;;AAKA,SAAgB,0BAAoD,OAA4C;CAC9G,OAAO,QAAQ;EAAE,UAAU,MAAM;EAAU,MAAM,MAAM;CAAK,IAAI,KAAA;AAClE;;AAGA,SAAgB,iBAAiB,GAAmB,GAAwC;CAC1F,OAAO,CAAC,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE;AAClF;;AAGA,SAAgB,iBAAiB,GAAmB,GAAwC;CAC1F,OAAO,CAAC,CAAC,MAAM,EAAE,YAAY,SAAS,EAAE,YAAY,OAAO,EAAE,SAAS,EAAE;AAC1E;;AAGA,MAAa,qBAAqB,cAAyB;CACzD,OAAO,UAAU,sBAAsB,UAAU,wBAAwB;AAC3E"} \ No newline at end of file diff --git a/dist/default/media/mp4/box.js b/dist/default/media/mp4/box.js new file mode 100644 index 00000000..69afa3db --- /dev/null +++ b/dist/default/media/mp4/box.js @@ -0,0 +1,58 @@ +//#region src/media/mp4/box.ts +function toDataView(data) { + return data instanceof Uint8Array ? new DataView(data.buffer, data.byteOffset, data.byteLength) : new DataView(data); +} +/** Read a 4-character box type / FourCC at `offset`. */ +function readFourCC(view, offset) { + return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3)); +} +/** Iterate the boxes directly contained in `[start, end)`. */ +function* iterateBoxes(view, start = 0, end = view.byteLength) { + let offset = start; + while (offset + 8 <= end) { + let size = view.getUint32(offset); + const type = readFourCC(view, offset + 4); + let dataStart = offset + 8; + if (size === 1) { + size = Number(view.getBigUint64(offset + 8)); + dataStart = offset + 16; + } else if (size === 0) size = end - offset; + if (size < dataStart - offset) return; + yield { + type, + start: offset, + dataStart, + end: offset + size + }; + offset += size; + } +} +/** Iterate the direct child boxes of a given `type` within `[start, end)`. */ +function* iterateBoxesOfType(view, type, start = 0, end = view.byteLength) { + for (const box of iterateBoxes(view, start, end)) if (box.type === type) yield box; +} +/** +* Depth-first descent to the first box matching a nested path, e.g. +* `['moov', 'trak', 'mdia', 'mdhd']`. Returns `undefined` if any level is +* absent. +*/ +function findBox(view, path, start = 0, end = view.byteLength) { + const [head, ...rest] = path; + for (const box of iterateBoxes(view, start, end)) { + if (box.type !== head) continue; + if (rest.length === 0) return box; + const found = findBox(view, rest, box.dataStart, box.end); + if (found) return found; + } +} +/** +* Read the version byte of a FullBox — the `version(1) + flags(3)` header at the +* start of the payload of `mdhd` / `tkhd` / `tfdt` / `hdlr` / `elst` / etc. +*/ +function readFullBoxVersion(view, dataStart) { + return view.getUint8(dataStart); +} +//#endregion +export { findBox, iterateBoxes, iterateBoxesOfType, readFourCC, readFullBoxVersion, toDataView }; + +//# sourceMappingURL=box.js.map \ No newline at end of file diff --git a/dist/default/media/mp4/box.js.map b/dist/default/media/mp4/box.js.map new file mode 100644 index 00000000..1262e9e2 --- /dev/null +++ b/dist/default/media/mp4/box.js.map @@ -0,0 +1 @@ +{"version":3,"file":"box.js","names":[],"sources":["../../../../src/media/mp4/box.ts"],"sourcesContent":["/**\n * Minimal ISO-BMFF (MP4/CMAF) box walker.\n *\n * Just enough to locate boxes by nested path — the framework needs a couple of\n * leaf fields (`mdhd` timescale, `tfdt` baseMediaDecodeTime) to derive a\n * segment's decode-time origin, not a full demuxer. DOM-free: operates on an\n * `ArrayBuffer` / `Uint8Array` via `DataView`.\n *\n * Box layout: `[u32 size][u32 type][payload]`. `size === 1` means a `u64\n * largesize` follows the type (payload after it); `size === 0` means the box\n * runs to the end of its container.\n */\n\n/** A located box: its 4-char type and byte offsets within the buffer. */\nexport interface Box {\n type: string;\n /** Offset of the box's first byte (its size field). */\n start: number;\n /** Offset of the box's payload — after `size` + `type` (+ `largesize`). */\n dataStart: number;\n /** Offset one past the box's last byte. */\n end: number;\n}\n\nexport function toDataView(data: ArrayBuffer | Uint8Array): DataView {\n return data instanceof Uint8Array ? new DataView(data.buffer, data.byteOffset, data.byteLength) : new DataView(data);\n}\n\n/** Read a 4-character box type / FourCC at `offset`. */\nexport function readFourCC(view: DataView, offset: number): string {\n return String.fromCharCode(\n view.getUint8(offset),\n view.getUint8(offset + 1),\n view.getUint8(offset + 2),\n view.getUint8(offset + 3)\n );\n}\n\n/** Iterate the boxes directly contained in `[start, end)`. */\nexport function* iterateBoxes(view: DataView, start = 0, end = view.byteLength): Generator {\n let offset = start;\n while (offset + 8 <= end) {\n let size = view.getUint32(offset);\n const type = readFourCC(view, offset + 4);\n let dataStart = offset + 8;\n if (size === 1) {\n size = Number(view.getBigUint64(offset + 8));\n dataStart = offset + 16;\n } else if (size === 0) {\n size = end - offset;\n }\n // A size smaller than its own header is malformed — stop rather than loop.\n if (size < dataStart - offset) return;\n yield { type, start: offset, dataStart, end: offset + size };\n offset += size;\n }\n}\n\n/** Iterate the direct child boxes of a given `type` within `[start, end)`. */\nexport function* iterateBoxesOfType(view: DataView, type: string, start = 0, end = view.byteLength): Generator {\n for (const box of iterateBoxes(view, start, end)) {\n if (box.type === type) yield box;\n }\n}\n\n/**\n * Depth-first descent to the first box matching a nested path, e.g.\n * `['moov', 'trak', 'mdia', 'mdhd']`. Returns `undefined` if any level is\n * absent.\n */\nexport function findBox(view: DataView, path: readonly string[], start = 0, end = view.byteLength): Box | undefined {\n const [head, ...rest] = path;\n for (const box of iterateBoxes(view, start, end)) {\n if (box.type !== head) continue;\n if (rest.length === 0) return box;\n const found = findBox(view, rest, box.dataStart, box.end);\n if (found) return found;\n }\n return undefined;\n}\n\n/**\n * Read the version byte of a FullBox — the `version(1) + flags(3)` header at the\n * start of the payload of `mdhd` / `tkhd` / `tfdt` / `hdlr` / `elst` / etc.\n */\nexport function readFullBoxVersion(view: DataView, dataStart: number): number {\n return view.getUint8(dataStart);\n}\n"],"mappings":";AAwBA,SAAgB,WAAW,MAA0C;CACnE,OAAO,gBAAgB,aAAa,IAAI,SAAS,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU,IAAI,IAAI,SAAS,IAAI;AACrH;;AAGA,SAAgB,WAAW,MAAgB,QAAwB;CACjE,OAAO,OAAO,aACZ,KAAK,SAAS,MAAM,GACpB,KAAK,SAAS,SAAS,CAAC,GACxB,KAAK,SAAS,SAAS,CAAC,GACxB,KAAK,SAAS,SAAS,CAAC,CAC1B;AACF;;AAGA,UAAiB,aAAa,MAAgB,QAAQ,GAAG,MAAM,KAAK,YAA4B;CAC9F,IAAI,SAAS;CACb,OAAO,SAAS,KAAK,KAAK;EACxB,IAAI,OAAO,KAAK,UAAU,MAAM;EAChC,MAAM,OAAO,WAAW,MAAM,SAAS,CAAC;EACxC,IAAI,YAAY,SAAS;EACzB,IAAI,SAAS,GAAG;GACd,OAAO,OAAO,KAAK,aAAa,SAAS,CAAC,CAAC;GAC3C,YAAY,SAAS;EACvB,OAAO,IAAI,SAAS,GAClB,OAAO,MAAM;EAGf,IAAI,OAAO,YAAY,QAAQ;EAC/B,MAAM;GAAE;GAAM,OAAO;GAAQ;GAAW,KAAK,SAAS;EAAK;EAC3D,UAAU;CACZ;AACF;;AAGA,UAAiB,mBAAmB,MAAgB,MAAc,QAAQ,GAAG,MAAM,KAAK,YAA4B;CAClH,KAAK,MAAM,OAAO,aAAa,MAAM,OAAO,GAAG,GAC7C,IAAI,IAAI,SAAS,MAAM,MAAM;AAEjC;;;;;;AAOA,SAAgB,QAAQ,MAAgB,MAAyB,QAAQ,GAAG,MAAM,KAAK,YAA6B;CAClH,MAAM,CAAC,MAAM,GAAG,QAAQ;CACxB,KAAK,MAAM,OAAO,aAAa,MAAM,OAAO,GAAG,GAAG;EAChD,IAAI,IAAI,SAAS,MAAM;EACvB,IAAI,KAAK,WAAW,GAAG,OAAO;EAC9B,MAAM,QAAQ,QAAQ,MAAM,MAAM,IAAI,WAAW,IAAI,GAAG;EACxD,IAAI,OAAO,OAAO;CACpB;AAEF;;;;;AAMA,SAAgB,mBAAmB,MAAgB,WAA2B;CAC5E,OAAO,KAAK,SAAS,SAAS;AAChC"} \ No newline at end of file diff --git a/dist/default/media/mp4/timestamp-origin.js b/dist/default/media/mp4/timestamp-origin.js new file mode 100644 index 00000000..3462a81c --- /dev/null +++ b/dist/default/media/mp4/timestamp-origin.js @@ -0,0 +1,107 @@ +import { findBox, iterateBoxesOfType, readFourCC, readFullBoxVersion, toDataView } from "./box.js"; +//#region src/media/mp4/timestamp-origin.ts +/** +* Decode-time origin extraction from fMP4/CMAF segments. +* +* Non-zero-PTS sources encode media at a non-zero start time (an instant clip +* starting at asset second 60, an Apple bipbop asset starting at 10s, …). To +* relocate such a source onto a 0-based presentation timeline via +* `SourceBuffer.timestampOffset`, the engine needs that start time — the +* **decode-time origin** — read straight from the container: +* +* - `tfdt.baseMediaDecodeTime` (from a media segment's `moof`) — the decode +* time of the segment's first sample, in the track's media timescale. This is +* a DTS: relocating by `−(baseMediaDecodeTime / timescale)` lands the earliest +* DTS at exactly 0, so a negative-DTS append failure on Chromium is impossible +* by construction. +* - `mdhd.timescale` (from the init segment's `moov`) — ticks per second for +* that track, needed to convert the raw tick count to seconds. +* +* ## Presumptive vs. track-selected reads +* +* The two variants share only the leaf field-readers (`mdhd` timescale, `tfdt` +* baseMediaDecodeTime) and the box walker. Everything else differs, and the +* split is deliberate so the presumptive pair is *proportionally* smaller under +* tree-shaking: +* +* - **Presumptive** — {@link readFirstMediaTimescale} / {@link readFirstBaseMediaDecodeTime} +* read the first `mdhd` timescale and first `tfdt` baseMediaDecodeTime. There's +* no `track_id` (nothing to match against) and no `trak`/`traf` iteration — a +* direct `findBox` to the first leaf. Correct when the init/segment holds a +* single media track (the common CMAF case). A caption-free platform imports +* only this pair and tree-shakes away all the matching machinery below. +* - **Track-selected** — {@link findMediaTrack} / {@link readBaseMediaDecodeTime}. +* `findMediaTrack` returns `{ trackId, timescale }` for the `trak` whose `hdlr` +* handler matches; `readBaseMediaDecodeTime` takes that `trackId` and reads the +* `traf` whose `tfhd.track_id` matches. The `track_id` is the join that ties one +* track's timescale to the *same* track's baseMediaDecodeTime — required when a +* source muxes CEA-608/708 captions (a `clcp` track shares the same `moov` and +* `moof`, each track with its own timescale + baseMediaDecodeTime, so a +* presumptive read there risks `300000 / 6000 = 50s` instead of +* `60000 / 6000 = 10s`). This pair adds `trak`/`traf` iteration plus the handler +* and `track_id` reads. We only ever ask for buffered media handlers +* (`vide` / `soun`); the caption track is never selected — we read the origin, +* not the captions (caption *rendering* is out of scope). +* +* Both raw values are returned un-divided to leave room for an edit-list (`elst`) +* presentation-time correction term if a source ever carries one — the validated +* streams do not. +*/ +/** +* The `track_ID` + timescale of the `trak` whose `mdhd` handler matches +* `handlerType`, skipping a muxed `clcp` caption track. `undefined` if no +* matching track exists. +*/ +function findMediaTrack(initSegment, handlerType) { + const view = toDataView(initSegment); + const moov = findBox(view, ["moov"]); + if (!moov) return void 0; + for (const trak of iterateBoxesOfType(view, "trak", moov.dataStart, moov.end)) { + if (readTrakHandler(view, trak) !== handlerType) continue; + const tkhd = findBox(view, ["tkhd"], trak.dataStart, trak.end); + const mdhd = findBox(view, ["mdia", "mdhd"], trak.dataStart, trak.end); + if (!tkhd || !mdhd) return void 0; + return { + trackId: view.getUint32(tkhd.dataStart + 4 + (readFullBoxVersion(view, tkhd.dataStart) === 1 ? 16 : 8)), + timescale: readMdhdTimescale(view, mdhd) + }; + } +} +/** +* `baseMediaDecodeTime` (in the track's media timescale) from the `traf` matching +* `trackId` (via `tfhd.track_id`) — required for muxed multi-`traf` segments. +* `undefined` if no matching `tfdt` exists. +*/ +function readBaseMediaDecodeTime(mediaSegment, trackId) { + const view = toDataView(mediaSegment); + const moof = findBox(view, ["moof"]); + if (!moof) return void 0; + for (const traf of iterateBoxesOfType(view, "traf", moof.dataStart, moof.end)) { + if (readTrafTrackId(view, traf) !== trackId) continue; + const tfdt = findBox(view, ["tfdt"], traf.dataStart, traf.end); + return tfdt ? readTfdtBaseMediaDecodeTime(view, tfdt) : void 0; + } +} +/** `mdhd.timescale`: FullBox version(1)+flags(3) + dates (v0: 4+4, v1: 8+8) + timescale. */ +function readMdhdTimescale(view, mdhd) { + return view.getUint32(mdhd.dataStart + 4 + (readFullBoxVersion(view, mdhd.dataStart) === 1 ? 16 : 8)); +} +/** `tfdt.baseMediaDecodeTime`: FullBox, then the value — v0: (4), v1: (8). */ +function readTfdtBaseMediaDecodeTime(view, tfdt) { + const at = tfdt.dataStart + 4; + return readFullBoxVersion(view, tfdt.dataStart) === 1 ? Number(view.getBigUint64(at)) : view.getUint32(at); +} +/** `hdlr.handler_type` for a `trak`: FullBox version(1)+flags(3) + pre_defined(4) + handler_type(4). */ +function readTrakHandler(view, trak) { + const hdlr = findBox(view, ["mdia", "hdlr"], trak.dataStart, trak.end); + return hdlr ? readFourCC(view, hdlr.dataStart + 8) : void 0; +} +/** `tfhd.track_id` for a `traf`: FullBox version(1)+flags(3) + track_id(4). */ +function readTrafTrackId(view, traf) { + const tfhd = findBox(view, ["tfhd"], traf.dataStart, traf.end); + return tfhd ? view.getUint32(tfhd.dataStart + 4) : void 0; +} +//#endregion +export { findMediaTrack, readBaseMediaDecodeTime }; + +//# sourceMappingURL=timestamp-origin.js.map \ No newline at end of file diff --git a/dist/default/media/mp4/timestamp-origin.js.map b/dist/default/media/mp4/timestamp-origin.js.map new file mode 100644 index 00000000..7270a8e7 --- /dev/null +++ b/dist/default/media/mp4/timestamp-origin.js.map @@ -0,0 +1 @@ +{"version":3,"file":"timestamp-origin.js","names":[],"sources":["../../../../src/media/mp4/timestamp-origin.ts"],"sourcesContent":["/**\n * Decode-time origin extraction from fMP4/CMAF segments.\n *\n * Non-zero-PTS sources encode media at a non-zero start time (an instant clip\n * starting at asset second 60, an Apple bipbop asset starting at 10s, …). To\n * relocate such a source onto a 0-based presentation timeline via\n * `SourceBuffer.timestampOffset`, the engine needs that start time — the\n * **decode-time origin** — read straight from the container:\n *\n * - `tfdt.baseMediaDecodeTime` (from a media segment's `moof`) — the decode\n * time of the segment's first sample, in the track's media timescale. This is\n * a DTS: relocating by `−(baseMediaDecodeTime / timescale)` lands the earliest\n * DTS at exactly 0, so a negative-DTS append failure on Chromium is impossible\n * by construction.\n * - `mdhd.timescale` (from the init segment's `moov`) — ticks per second for\n * that track, needed to convert the raw tick count to seconds.\n *\n * ## Presumptive vs. track-selected reads\n *\n * The two variants share only the leaf field-readers (`mdhd` timescale, `tfdt`\n * baseMediaDecodeTime) and the box walker. Everything else differs, and the\n * split is deliberate so the presumptive pair is *proportionally* smaller under\n * tree-shaking:\n *\n * - **Presumptive** — {@link readFirstMediaTimescale} / {@link readFirstBaseMediaDecodeTime}\n * read the first `mdhd` timescale and first `tfdt` baseMediaDecodeTime. There's\n * no `track_id` (nothing to match against) and no `trak`/`traf` iteration — a\n * direct `findBox` to the first leaf. Correct when the init/segment holds a\n * single media track (the common CMAF case). A caption-free platform imports\n * only this pair and tree-shakes away all the matching machinery below.\n * - **Track-selected** — {@link findMediaTrack} / {@link readBaseMediaDecodeTime}.\n * `findMediaTrack` returns `{ trackId, timescale }` for the `trak` whose `hdlr`\n * handler matches; `readBaseMediaDecodeTime` takes that `trackId` and reads the\n * `traf` whose `tfhd.track_id` matches. The `track_id` is the join that ties one\n * track's timescale to the *same* track's baseMediaDecodeTime — required when a\n * source muxes CEA-608/708 captions (a `clcp` track shares the same `moov` and\n * `moof`, each track with its own timescale + baseMediaDecodeTime, so a\n * presumptive read there risks `300000 / 6000 = 50s` instead of\n * `60000 / 6000 = 10s`). This pair adds `trak`/`traf` iteration plus the handler\n * and `track_id` reads. We only ever ask for buffered media handlers\n * (`vide` / `soun`); the caption track is never selected — we read the origin,\n * not the captions (caption *rendering* is out of scope).\n *\n * Both raw values are returned un-divided to leave room for an edit-list (`elst`)\n * presentation-time correction term if a source ever carries one — the validated\n * streams do not.\n */\nimport { type Box, findBox, iterateBoxesOfType, readFourCC, readFullBoxVersion, toDataView } from './box';\n\n/** MSE-buffered media handler types (`mdhd`/`hdlr`). Captions/subtitles excluded. */\nexport type MediaHandlerType = 'vide' | 'soun';\n\nexport interface MediaTrackInfo {\n /** `tkhd.track_id` — used to match the corresponding `traf` in media segments. */\n trackId: number;\n /** `mdhd.timescale` — ticks per second for this track. */\n timescale: number;\n}\n\n// --- presumptive: first leaf, no track_id, no iteration -----------------------\n\n/**\n * Presumptive: the `timescale` of the **first** `mdhd` in an init segment.\n * Correct only for single-media-track inits — for muxed captions use\n * {@link findMediaTrack}. `undefined` if no `mdhd` exists.\n */\nexport function readFirstMediaTimescale(initSegment: ArrayBuffer | Uint8Array): number | undefined {\n const view = toDataView(initSegment);\n const mdhd = findBox(view, ['moov', 'trak', 'mdia', 'mdhd']);\n return mdhd ? readMdhdTimescale(view, mdhd) : undefined;\n}\n\n/**\n * Presumptive: `baseMediaDecodeTime` from the **first** `tfdt` of a media\n * segment. Correct only for single-`traf` segments — for muxed captions use\n * {@link readBaseMediaDecodeTime}. `undefined` if no `tfdt` exists.\n */\nexport function readFirstBaseMediaDecodeTime(mediaSegment: ArrayBuffer | Uint8Array): number | undefined {\n const view = toDataView(mediaSegment);\n const tfdt = findBox(view, ['moof', 'traf', 'tfdt']);\n return tfdt ? readTfdtBaseMediaDecodeTime(view, tfdt) : undefined;\n}\n\n// --- track-selected: iterate + match by handler / track_id --------------------\n\n/**\n * The `track_ID` + timescale of the `trak` whose `mdhd` handler matches\n * `handlerType`, skipping a muxed `clcp` caption track. `undefined` if no\n * matching track exists.\n */\nexport function findMediaTrack(\n initSegment: ArrayBuffer | Uint8Array,\n handlerType: MediaHandlerType\n): MediaTrackInfo | undefined {\n const view = toDataView(initSegment);\n const moov = findBox(view, ['moov']);\n if (!moov) return undefined;\n\n for (const trak of iterateBoxesOfType(view, 'trak', moov.dataStart, moov.end)) {\n if (readTrakHandler(view, trak) !== handlerType) continue;\n const tkhd = findBox(view, ['tkhd'], trak.dataStart, trak.end);\n const mdhd = findBox(view, ['mdia', 'mdhd'], trak.dataStart, trak.end);\n if (!tkhd || !mdhd) return undefined;\n // tkhd FullBox: version(1)+flags(3), creation/modification dates (v0: 4+4, v1:\n // 8+8), then track_id.\n const trackId = view.getUint32(tkhd.dataStart + 4 + (readFullBoxVersion(view, tkhd.dataStart) === 1 ? 16 : 8));\n return { trackId, timescale: readMdhdTimescale(view, mdhd) };\n }\n return undefined;\n}\n\n/**\n * `baseMediaDecodeTime` (in the track's media timescale) from the `traf` matching\n * `trackId` (via `tfhd.track_id`) — required for muxed multi-`traf` segments.\n * `undefined` if no matching `tfdt` exists.\n */\nexport function readBaseMediaDecodeTime(mediaSegment: ArrayBuffer | Uint8Array, trackId: number): number | undefined {\n const view = toDataView(mediaSegment);\n const moof = findBox(view, ['moof']);\n if (!moof) return undefined;\n\n for (const traf of iterateBoxesOfType(view, 'traf', moof.dataStart, moof.end)) {\n if (readTrafTrackId(view, traf) !== trackId) continue;\n const tfdt = findBox(view, ['tfdt'], traf.dataStart, traf.end);\n return tfdt ? readTfdtBaseMediaDecodeTime(view, tfdt) : undefined;\n }\n return undefined;\n}\n\n// --- shared leaf field-readers ------------------------------------------------\n\n/** `mdhd.timescale`: FullBox version(1)+flags(3) + dates (v0: 4+4, v1: 8+8) + timescale. */\nfunction readMdhdTimescale(view: DataView, mdhd: Box): number {\n return view.getUint32(mdhd.dataStart + 4 + (readFullBoxVersion(view, mdhd.dataStart) === 1 ? 16 : 8));\n}\n\n/** `tfdt.baseMediaDecodeTime`: FullBox, then the value — v0: (4), v1: (8). */\nfunction readTfdtBaseMediaDecodeTime(view: DataView, tfdt: Box): number {\n const at = tfdt.dataStart + 4;\n return readFullBoxVersion(view, tfdt.dataStart) === 1 ? Number(view.getBigUint64(at)) : view.getUint32(at);\n}\n\n// --- track-selection readers (referenced only by the track-selected variants) -\n\n/** `hdlr.handler_type` for a `trak`: FullBox version(1)+flags(3) + pre_defined(4) + handler_type(4). */\nfunction readTrakHandler(view: DataView, trak: Box): string | undefined {\n const hdlr = findBox(view, ['mdia', 'hdlr'], trak.dataStart, trak.end);\n return hdlr ? readFourCC(view, hdlr.dataStart + 8) : undefined;\n}\n\n/** `tfhd.track_id` for a `traf`: FullBox version(1)+flags(3) + track_id(4). */\nfunction readTrafTrackId(view: DataView, traf: Box): number | undefined {\n const tfhd = findBox(view, ['tfhd'], traf.dataStart, traf.end);\n return tfhd ? view.getUint32(tfhd.dataStart + 4) : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0FA,SAAgB,eACd,aACA,aAC4B;CAC5B,MAAM,OAAO,WAAW,WAAW;CACnC,MAAM,OAAO,QAAQ,MAAM,CAAC,MAAM,CAAC;CACnC,IAAI,CAAC,MAAM,OAAO,KAAA;CAElB,KAAK,MAAM,QAAQ,mBAAmB,MAAM,QAAQ,KAAK,WAAW,KAAK,GAAG,GAAG;EAC7E,IAAI,gBAAgB,MAAM,IAAI,MAAM,aAAa;EACjD,MAAM,OAAO,QAAQ,MAAM,CAAC,MAAM,GAAG,KAAK,WAAW,KAAK,GAAG;EAC7D,MAAM,OAAO,QAAQ,MAAM,CAAC,QAAQ,MAAM,GAAG,KAAK,WAAW,KAAK,GAAG;EACrE,IAAI,CAAC,QAAQ,CAAC,MAAM,OAAO,KAAA;EAI3B,OAAO;GAAE,SADO,KAAK,UAAU,KAAK,YAAY,KAAK,mBAAmB,MAAM,KAAK,SAAS,MAAM,IAAI,KAAK,EAC5F;GAAG,WAAW,kBAAkB,MAAM,IAAI;EAAE;CAC7D;AAEF;;;;;;AAOA,SAAgB,wBAAwB,cAAwC,SAAqC;CACnH,MAAM,OAAO,WAAW,YAAY;CACpC,MAAM,OAAO,QAAQ,MAAM,CAAC,MAAM,CAAC;CACnC,IAAI,CAAC,MAAM,OAAO,KAAA;CAElB,KAAK,MAAM,QAAQ,mBAAmB,MAAM,QAAQ,KAAK,WAAW,KAAK,GAAG,GAAG;EAC7E,IAAI,gBAAgB,MAAM,IAAI,MAAM,SAAS;EAC7C,MAAM,OAAO,QAAQ,MAAM,CAAC,MAAM,GAAG,KAAK,WAAW,KAAK,GAAG;EAC7D,OAAO,OAAO,4BAA4B,MAAM,IAAI,IAAI,KAAA;CAC1D;AAEF;;AAKA,SAAS,kBAAkB,MAAgB,MAAmB;CAC5D,OAAO,KAAK,UAAU,KAAK,YAAY,KAAK,mBAAmB,MAAM,KAAK,SAAS,MAAM,IAAI,KAAK,EAAE;AACtG;;AAGA,SAAS,4BAA4B,MAAgB,MAAmB;CACtE,MAAM,KAAK,KAAK,YAAY;CAC5B,OAAO,mBAAmB,MAAM,KAAK,SAAS,MAAM,IAAI,OAAO,KAAK,aAAa,EAAE,CAAC,IAAI,KAAK,UAAU,EAAE;AAC3G;;AAKA,SAAS,gBAAgB,MAAgB,MAA+B;CACtE,MAAM,OAAO,QAAQ,MAAM,CAAC,QAAQ,MAAM,GAAG,KAAK,WAAW,KAAK,GAAG;CACrE,OAAO,OAAO,WAAW,MAAM,KAAK,YAAY,CAAC,IAAI,KAAA;AACvD;;AAGA,SAAS,gBAAgB,MAAgB,MAA+B;CACtE,MAAM,OAAO,QAAQ,MAAM,CAAC,MAAM,GAAG,KAAK,WAAW,KAAK,GAAG;CAC7D,OAAO,OAAO,KAAK,UAAU,KAAK,YAAY,CAAC,IAAI,KAAA;AACrD"} \ No newline at end of file diff --git a/dist/default/media/primitives/select-tracks.js b/dist/default/media/primitives/select-tracks.js new file mode 100644 index 00000000..2ffa72ef --- /dev/null +++ b/dist/default/media/primitives/select-tracks.js @@ -0,0 +1,130 @@ +//#region src/media/primitives/select-tracks.ts +/** +* Test whether a track matches a partial-track description: every present, +* defined field of `filter` equals the track's. Absent or `undefined` filter +* fields don't constrain. Used to narrow candidates by a user selection +* (`{ id }`, `{ language }`, `{ height }`, …). +* +* @param track - The track to test +* @param filter - Partial-track description; only present, defined fields constrain +* @returns `true` when the track matches every constraining field +*/ +function matchesPartialTrack(track, filter) { + for (const key in filter) { + const filterValue = filter[key]; + if (filterValue !== void 0 && track[key] !== filterValue) return false; + } + return true; +} +/** +* Pick the first track of the given type from a presentation. +* +* Returns the first track in the first switching set of the matching +* selection set, or `undefined` if either is missing. POC-shaped +* default-pick — `pickVideoTrack` / `pickAudioTrack` honor bandwidth + +* language preferences and will replace this once selection callers are +* ready. +*/ +function pickFirstTrackId(presentation, type) { + return presentation.selectionSets?.find((set) => set.type === type)?.switchingSets[0]?.tracks[0]?.id; +} +/** +* Translates a "max resolution" into a total total pixel area +* for comparisons with video track resolutions with an assumed +* 16:9 ratio. +* +* Example: "720p" translates to a 921600 pixel area. +* +* Because 720 * 1280 = 720 * (720 * (16/9) ) = 921_600 +* +* Accepts: +* - string with the format '{height}p'. ('720p') +* - bare number, interpreted as pixel area. (921_600) +* - anything else will translate to `+Infinity`, meaning no cap specified +*/ +function maxResolutionToPixelArea(value) { + if (value === void 0 || value === null) return Number.POSITIVE_INFINITY; + if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : Number.POSITIVE_INFINITY; + const match = value.trim().match(/^(\d+)p?$/i); + if (!match) return Number.POSITIVE_INFINITY; + const height = Number(match[1]); + if (!(Number.isFinite(height) && height > 0)) return Number.POSITIVE_INFINITY; + return height * height * 16 / 9; +} +/** +* Pick the track with the highest pixel area at or below `maxPixelArea`. +* Falls back to the lowest track when nothing satisfies the cap (the +* lowest of the above-cap set is the closest to the cap from above). +* Tiebreak on bandwidth. Missing dimensions are treated as area `0`. +*/ +function pickTrackUnderPixelArea(tracks, maxPixelArea = Number.POSITIVE_INFINITY) { + if (tracks.length === 0) return void 0; + const sorted = [...tracks].sort((a, b) => (b.width ?? 0) * (b.height ?? 0) - (a.width ?? 0) * (a.height ?? 0) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0)); + return sorted.find((t) => (t.width ?? 0) * (t.height ?? 0) <= maxPixelArea) ?? sorted[sorted.length - 1]; +} +/** +* Pick the video track with the highest pixel area. +* +* Pair with `selectVideoTrack`; compose `switchVideoQuality` instead +* for runtime-adapted quality. +*/ +function pickHighestResolutionVideoTrack(presentation) { + const tracks = (presentation.selectionSets?.find((set) => set.type === "video"))?.switchingSets[0]?.tracks; + if (!tracks?.length) return void 0; + return pickTrackUnderPixelArea(tracks)?.id; +} +/** +* Pick audio track. +* +* Selection priority: +* 1. First track matching preferred language (if specified) +* 2. First default track +* 3. First audio track +* +* @param presentation - Presentation with audio tracks +* @param config - Selection configuration (preferred language) +* @returns Selected audio track ID, or undefined if no audio tracks +*/ +function pickAudioTrack(presentation, config) { + const audioSet = presentation.selectionSets?.find((set) => set.type === "audio"); + if (!audioSet || audioSet.switchingSets.length === 0) return; + const switchingSet = audioSet.switchingSets[0]; + if (!switchingSet || switchingSet.tracks.length === 0) return; + const tracks = switchingSet.tracks; + if (config?.preferredAudioLanguage) { + const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage); + if (languageMatch) return languageMatch.id; + } + const defaultTrack = tracks.find((track) => track.default === true); + if (defaultTrack) return defaultTrack.id; + return tracks[0]?.id; +} +/** +* Default text-track policy over an explicit candidate list (rather than a whole +* presentation): the opt-in three-tier pick `pickTextTrack` delegates to, factored +* out so a caller that has already narrowed the candidates — a constrained, +* CDN-scoped track-switching chain — applies the same policy without re-deriving +* from the presentation. +* +* Priority: `preferredSubtitleLanguage` match → `DEFAULT=YES + AUTOSELECT=YES` +* (only when `enableDefaultTrack`) → `undefined` (opt-in). FORCED tracks are +* excluded unless `includeForcedTracks` (Apple-spec: a regular track must carry +* forced content when both exist, so a forced-only track is redundant). +*/ +function pickTextTrackFromTracks(tracks, config) { + const availableTracks = config?.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced); + if (availableTracks.length === 0) return void 0; + const { preferredSubtitleLanguage, enableDefaultTrack = false } = config ?? {}; + if (preferredSubtitleLanguage) { + const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage); + if (languageMatch) return languageMatch.id; + } + if (enableDefaultTrack) { + const defaultTrack = availableTracks.find((track) => track.default === true); + if (defaultTrack) return defaultTrack.id; + } +} +//#endregion +export { matchesPartialTrack, maxResolutionToPixelArea, pickAudioTrack, pickFirstTrackId, pickHighestResolutionVideoTrack, pickTextTrackFromTracks, pickTrackUnderPixelArea }; + +//# sourceMappingURL=select-tracks.js.map \ No newline at end of file diff --git a/dist/default/media/primitives/select-tracks.js.map b/dist/default/media/primitives/select-tracks.js.map new file mode 100644 index 00000000..01948d58 --- /dev/null +++ b/dist/default/media/primitives/select-tracks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/media/primitives/select-tracks.ts"],"sourcesContent":["import { DEFAULT_QUALITY_CONFIG, selectQuality } from '../abr/quality-selection';\nimport type {\n AudioSelectionSet,\n MaybeResolvedPresentation,\n PartiallyResolvedTextTrack,\n TextTrack,\n TrackType,\n VideoSelectionSet,\n} from '../types';\nimport { SelectedTrackIdKeyByType } from '../utils/track-selection';\n\n/**\n * Default initial bandwidth estimate for cold start (bits per second).\n * Conservative 1 Mbps to avoid over-selecting on slow connections.\n */\nexport const DEFAULT_INITIAL_BANDWIDTH = 1_000_000;\n\n/**\n * State shape for track selection.\n */\nexport interface TrackSelectionState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/**\n * Context shape for track selection.\n * Currently empty - reserved for future use (e.g., bandwidth estimator).\n */\nexport type TrackSelectionContext = Record;\n\n/**\n * Action types for track selection.\n * Reserved for future event-driven selection triggers.\n */\nexport type TrackSelectionAction = { type: 'presentation-loaded' };\n\n/**\n * Configuration for video track selection.\n */\nexport interface VideoSelectionConfig {\n /**\n * Initial bandwidth estimate for cold start (bits per second).\n * Used to select video quality before we have real measurements.\n * Default: 1 Mbps (conservative).\n */\n initialBandwidth?: number;\n\n /**\n * Safety margin for quality selection (0-1).\n * Default: 0.85 (15% headroom).\n */\n safetyMargin?: number;\n}\n\n/**\n * Configuration for audio track selection.\n */\nexport interface AudioSelectionConfig {\n /**\n * Preferred audio language (ISO 639 code, e.g., \"en\", \"es\").\n * If not specified, selects first audio track.\n */\n preferredAudioLanguage?: string;\n}\n\n/**\n * Configuration for text track selection.\n */\nexport interface TextSelectionConfig {\n /**\n * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching track if available.\n */\n preferredSubtitleLanguage?: string;\n\n /**\n * Include FORCED subtitle tracks in selection.\n * Default: false (follows hls.js/http-streaming pattern)\n *\n * Note: Per Apple's HLS spec, if content has forced and regular subtitles\n * in the same language, the regular track MUST contain both forced and\n * regular content. Therefore, forced-only tracks are redundant and excluded\n * by default.\n */\n includeForcedTracks?: boolean;\n\n /**\n * Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).\n * Default: false (user opt-in, matches hls.js/http-streaming)\n *\n * When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES\n * will be automatically selected if no user preference matches.\n */\n enableDefaultTrack?: boolean;\n}\n\n// =============================================================================\n// Helper Functions (Pure Selection Logic)\n// =============================================================================\n\n/**\n * Contract for a track picker — a pure function that consults a\n * presentation (and optional config) and returns the id of the track to\n * select, or `undefined` to leave the slot unset.\n *\n * Behaviors that own a track-selection slot (`selectAudioTrack`,\n * `selectVideoTrack`, `switchVideoTrack`) accept a\n * `TrackPicker` via config. The behavior passes its own config straight\n * through as the picker's second argument — pickers that need richer\n * options (language preferences, default-track filtering, bandwidth-aware\n * selection) read from `config`; pickers that don't (e.g., first-track)\n * ignore it.\n */\nexport type TrackPicker = (\n presentation: MaybeResolvedPresentation,\n config?: Config\n) => string | undefined;\n\n/**\n * Test whether a track matches a partial-track description: every present,\n * defined field of `filter` equals the track's. Absent or `undefined` filter\n * fields don't constrain. Used to narrow candidates by a user selection\n * (`{ id }`, `{ language }`, `{ height }`, …).\n *\n * @param track - The track to test\n * @param filter - Partial-track description; only present, defined fields constrain\n * @returns `true` when the track matches every constraining field\n */\nexport function matchesPartialTrack(track: T, filter: Partial): boolean {\n for (const key in filter) {\n const filterValue = filter[key as keyof T];\n if (filterValue !== undefined && track[key as keyof T] !== filterValue) return false;\n }\n return true;\n}\n\n/**\n * Pick the first track of the given type from a presentation.\n *\n * Returns the first track in the first switching set of the matching\n * selection set, or `undefined` if either is missing. POC-shaped\n * default-pick — `pickVideoTrack` / `pickAudioTrack` honor bandwidth +\n * language preferences and will replace this once selection callers are\n * ready.\n */\nexport function pickFirstTrackId(presentation: MaybeResolvedPresentation, type: TrackType): string | undefined {\n return presentation.selectionSets?.find((set) => set.type === type)?.switchingSets[0]?.tracks[0]?.id;\n}\n\n/**\n * Pick video track using quality selection algorithm.\n *\n * Uses bandwidth-based selection with safety margin to pick\n * the highest quality track that fits available bandwidth.\n *\n * @param presentation - Presentation with video tracks\n * @param config - Selection configuration (bandwidth, safety margin)\n * @returns Selected video track ID, or undefined if no video tracks\n */\nexport function pickVideoTrack(\n presentation: MaybeResolvedPresentation,\n config?: VideoSelectionConfig\n): string | undefined {\n const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;\n\n if (!videoSet || videoSet.switchingSets.length === 0) {\n return undefined;\n }\n\n // Get first switching set's tracks (HLS typically has one switching set per type)\n const switchingSet = videoSet.switchingSets[0];\n if (!switchingSet || switchingSet.tracks.length === 0) {\n return undefined;\n }\n\n const initialBandwidth = config?.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH;\n const safetyMargin = config?.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;\n\n // selectQuality works with both partially resolved and resolved tracks\n const selected = selectQuality(switchingSet.tracks as any, { bandwidth: initialBandwidth, safetyMargin });\n\n return selected?.id;\n}\n\n/**\n * Translates a \"max resolution\" into a total total pixel area\n * for comparisons with video track resolutions with an assumed\n * 16:9 ratio.\n *\n * Example: \"720p\" translates to a 921600 pixel area.\n *\n * Because 720 * 1280 = 720 * (720 * (16/9) ) = 921_600\n *\n * Accepts:\n * - string with the format '{height}p'. ('720p')\n * - bare number, interpreted as pixel area. (921_600)\n * - anything else will translate to `+Infinity`, meaning no cap specified\n */\nexport function maxResolutionToPixelArea(value: string | number | undefined): number {\n if (value === undefined || value === null) return Number.POSITIVE_INFINITY;\n if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : Number.POSITIVE_INFINITY;\n const match = value.trim().match(/^(\\d+)p?$/i);\n if (!match) return Number.POSITIVE_INFINITY;\n const height = Number(match[1]);\n if (!(Number.isFinite(height) && height > 0)) return Number.POSITIVE_INFINITY;\n return (height * height * 16) / 9;\n}\n\ntype RankableTrack = { id: string; width?: number; height?: number; bandwidth?: number };\n\n/**\n * Pick the track with the highest pixel area at or below `maxPixelArea`.\n * Falls back to the lowest track when nothing satisfies the cap (the\n * lowest of the above-cap set is the closest to the cap from above).\n * Tiebreak on bandwidth. Missing dimensions are treated as area `0`.\n */\nexport function pickTrackUnderPixelArea(\n tracks: readonly T[],\n maxPixelArea: number = Number.POSITIVE_INFINITY\n): T | undefined {\n if (tracks.length === 0) return undefined;\n\n // Sort descending by pixel area, bandwidth as tiebreaker. List sizes\n // are small (HLS variant counts) — no need to optimize past a sort.\n const sorted = [...tracks].sort(\n (a, b) =>\n (b.width ?? 0) * (b.height ?? 0) - (a.width ?? 0) * (a.height ?? 0) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0)\n );\n\n return sorted.find((t) => (t.width ?? 0) * (t.height ?? 0) <= maxPixelArea) ?? sorted[sorted.length - 1];\n}\n\n/**\n * Pick the video track with the highest pixel area.\n *\n * Pair with `selectVideoTrack`; compose `switchVideoQuality` instead\n * for runtime-adapted quality.\n */\nexport function pickHighestResolutionVideoTrack(presentation: MaybeResolvedPresentation): string | undefined {\n const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;\n const tracks = videoSet?.switchingSets[0]?.tracks;\n if (!tracks?.length) return undefined;\n return pickTrackUnderPixelArea(tracks)?.id;\n}\n\n/**\n * Pick audio track.\n *\n * Selection priority:\n * 1. First track matching preferred language (if specified)\n * 2. First default track\n * 3. First audio track\n *\n * @param presentation - Presentation with audio tracks\n * @param config - Selection configuration (preferred language)\n * @returns Selected audio track ID, or undefined if no audio tracks\n */\nexport function pickAudioTrack(\n presentation: MaybeResolvedPresentation,\n config?: AudioSelectionConfig\n): string | undefined {\n const audioSet = presentation.selectionSets?.find((set) => set.type === 'audio') as AudioSelectionSet | undefined;\n\n if (!audioSet || audioSet.switchingSets.length === 0) {\n return undefined;\n }\n\n // Get first switching set's tracks\n const switchingSet = audioSet.switchingSets[0];\n if (!switchingSet || switchingSet.tracks.length === 0) {\n return undefined;\n }\n\n const tracks = switchingSet.tracks;\n\n // Try preferred language first\n if (config?.preferredAudioLanguage) {\n const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);\n if (languageMatch) {\n return languageMatch.id;\n }\n }\n\n // Try default track\n const defaultTrack = tracks.find((track) => track.default === true);\n if (defaultTrack) {\n return defaultTrack.id;\n }\n\n // Fall back to first track\n return tracks[0]?.id;\n}\n\n/**\n * Pick text track to activate from a presentation. Conforms to the\n * `TrackPicker` contract. The candidate-list core (`pickTextTrackFromTracks`)\n * is the opt-in default policy `switchTextTrack`'s terminal applies once it has\n * narrowed the renditions.\n *\n * Selection priority (if enabled):\n * 1. User preference (preferredSubtitleLanguage)\n * 2. DEFAULT track (if enableDefaultTrack is true and track has DEFAULT=YES + AUTOSELECT=YES)\n * 3. No auto-selection (user opt-in)\n *\n * By default, FORCED tracks are excluded per Apple's HLS spec.\n */\nexport function pickTextTrack(\n presentation: MaybeResolvedPresentation,\n config?: TextSelectionConfig\n): string | undefined {\n const tracks = presentation.selectionSets?.find((set) => set.type === 'text')?.switchingSets?.[0]?.tracks;\n if (!tracks?.length) return undefined;\n return pickTextTrackFromTracks(tracks, config);\n}\n\n/**\n * Default text-track policy over an explicit candidate list (rather than a whole\n * presentation): the opt-in three-tier pick `pickTextTrack` delegates to, factored\n * out so a caller that has already narrowed the candidates — a constrained,\n * CDN-scoped track-switching chain — applies the same policy without re-deriving\n * from the presentation.\n *\n * Priority: `preferredSubtitleLanguage` match → `DEFAULT=YES + AUTOSELECT=YES`\n * (only when `enableDefaultTrack`) → `undefined` (opt-in). FORCED tracks are\n * excluded unless `includeForcedTracks` (Apple-spec: a regular track must carry\n * forced content when both exist, so a forced-only track is redundant).\n */\nexport function pickTextTrackFromTracks(\n tracks: readonly (PartiallyResolvedTextTrack | TextTrack)[],\n config?: TextSelectionConfig\n): string | undefined {\n const availableTracks = config?.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);\n if (availableTracks.length === 0) return undefined;\n\n const { preferredSubtitleLanguage, enableDefaultTrack = false } = config ?? {};\n\n if (preferredSubtitleLanguage) {\n const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);\n if (languageMatch) return languageMatch.id;\n }\n\n if (enableDefaultTrack) {\n const defaultTrack = availableTracks.find((track) => track.default === true);\n if (defaultTrack) return defaultTrack.id;\n }\n\n return undefined;\n}\n\n/**\n * Check if we can select a track of the given type.\n *\n * Returns true when:\n * - Presentation exists\n * - Has tracks of the specified type\n *\n * Generic over track type - works for video, audio, or text.\n */\nexport function canSelectTrack(state: TrackSelectionState, type: TrackType): boolean {\n return !!state?.presentation?.selectionSets?.find((set) => set.type === type)?.switchingSets?.[0]?.tracks.length;\n}\n\n/**\n * Check if we should select a track of the given type.\n *\n * Returns true when:\n * - Track of this type is not already selected\n *\n * Generic over track type - works for video, audio, or text.\n *\n * @TODO figure out reactive model for ABR cases - right now we're only selecting\n * if we have nothing selected (CJP)\n */\nexport function shouldSelectTrack(state: TrackSelectionState, type: TrackType): boolean {\n return !state[SelectedTrackIdKeyByType[type]];\n}\n"],"mappings":";;;;;;;;;;;AAmIA,SAAgB,oBAAuB,OAAU,QAA6B;CAC5E,KAAK,MAAM,OAAO,QAAQ;EACxB,MAAM,cAAc,OAAO;EAC3B,IAAI,gBAAgB,KAAA,KAAa,MAAM,SAAoB,aAAa,OAAO;CACjF;CACA,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,iBAAiB,cAAyC,MAAqC;CAC7G,OAAO,aAAa,eAAe,MAAM,QAAQ,IAAI,SAAS,IAAI,CAAC,EAAE,cAAc,EAAE,EAAE,OAAO,EAAE,EAAE;AACpG;;;;;;;;;;;;;;;AAmDA,SAAgB,yBAAyB,OAA4C;CACnF,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO,OAAO;CACzD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,IAAI,QAAQ,OAAO;CAC3F,MAAM,QAAQ,MAAM,KAAK,CAAC,CAAC,MAAM,YAAY;CAC7C,IAAI,CAAC,OAAO,OAAO,OAAO;CAC1B,MAAM,SAAS,OAAO,MAAM,EAAE;CAC9B,IAAI,EAAE,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,OAAO,OAAO;CAC5D,OAAQ,SAAS,SAAS,KAAM;AAClC;;;;;;;AAUA,SAAgB,wBACd,QACA,eAAuB,OAAO,mBACf;CACf,IAAI,OAAO,WAAW,GAAG,OAAO,KAAA;CAIhC,MAAM,SAAS,CAAC,GAAG,MAAM,CAAC,CAAC,MACxB,GAAG,OACD,EAAE,SAAS,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,MAAM,EAAE,UAAU,OAAO,EAAE,aAAa,MAAM,EAAE,aAAa,EAChH;CAEA,OAAO,OAAO,MAAM,OAAO,EAAE,SAAS,MAAM,EAAE,UAAU,MAAM,YAAY,KAAK,OAAO,OAAO,SAAS;AACxG;;;;;;;AAQA,SAAgB,gCAAgC,cAA6D;CAE3G,MAAM,UADW,aAAa,eAAe,MAAM,QAAQ,IAAI,SAAS,OAAO,EAAA,EACtD,cAAc,EAAE,EAAE;CAC3C,IAAI,CAAC,QAAQ,QAAQ,OAAO,KAAA;CAC5B,OAAO,wBAAwB,MAAM,CAAC,EAAE;AAC1C;;;;;;;;;;;;;AAcA,SAAgB,eACd,cACA,QACoB;CACpB,MAAM,WAAW,aAAa,eAAe,MAAM,QAAQ,IAAI,SAAS,OAAO;CAE/E,IAAI,CAAC,YAAY,SAAS,cAAc,WAAW,GACjD;CAIF,MAAM,eAAe,SAAS,cAAc;CAC5C,IAAI,CAAC,gBAAgB,aAAa,OAAO,WAAW,GAClD;CAGF,MAAM,SAAS,aAAa;CAG5B,IAAI,QAAQ,wBAAwB;EAClC,MAAM,gBAAgB,OAAO,MAAM,UAAU,MAAM,aAAa,OAAO,sBAAsB;EAC7F,IAAI,eACF,OAAO,cAAc;CAEzB;CAGA,MAAM,eAAe,OAAO,MAAM,UAAU,MAAM,YAAY,IAAI;CAClE,IAAI,cACF,OAAO,aAAa;CAItB,OAAO,OAAO,EAAE,EAAE;AACpB;;;;;;;;;;;;;AAoCA,SAAgB,wBACd,QACA,QACoB;CACpB,MAAM,kBAAkB,QAAQ,sBAAsB,SAAS,OAAO,QAAQ,UAAU,CAAC,MAAM,MAAM;CACrG,IAAI,gBAAgB,WAAW,GAAG,OAAO,KAAA;CAEzC,MAAM,EAAE,2BAA2B,qBAAqB,UAAU,UAAU,CAAC;CAE7E,IAAI,2BAA2B;EAC7B,MAAM,gBAAgB,gBAAgB,MAAM,UAAU,MAAM,aAAa,yBAAyB;EAClG,IAAI,eAAe,OAAO,cAAc;CAC1C;CAEA,IAAI,oBAAoB;EACtB,MAAM,eAAe,gBAAgB,MAAM,UAAU,MAAM,YAAY,IAAI;EAC3E,IAAI,cAAc,OAAO,aAAa;CACxC;AAGF"} \ No newline at end of file diff --git a/dist/default/media/text/parse-vtt-timestamp-map.js b/dist/default/media/text/parse-vtt-timestamp-map.js new file mode 100644 index 00000000..ddd21360 --- /dev/null +++ b/dist/default/media/text/parse-vtt-timestamp-map.js @@ -0,0 +1,42 @@ +//#region src/media/text/parse-vtt-timestamp-map.ts +const TIMESTAMP_MAP_PREFIX = "X-TIMESTAMP-MAP="; +/** +* Scrape a WebVTT segment's `X-TIMESTAMP-MAP` header into a {@link TimestampMap} +* — the only header line we need to correlate LOCAL cue times with the media +* presentation timeline. Deliberately *not* a WebVTT parser: cue parsing stays +* with the browser's native `` parser (which drops this line); this reads +* just the one header field the native path discards. +* +* Returns `undefined` when the segment carries no map (e.g. cues already in +* absolute presentation time) — per the HLS spec that means LOCAL 0 maps to +* MPEGTS 0. Tolerant of attribute order and `[HH:]MM:SS.mmm` LOCAL forms. +*/ +function parseVttTimestampMap(text) { + const timestampMapLine = text.split(/\r\n|\r|\n/).find((line) => line.startsWith(TIMESTAMP_MAP_PREFIX)); + return timestampMapLine ? parseTimestampMapBody(timestampMapLine.slice(16)) : void 0; +} +const TimeStampMapParserMap = { + LOCAL: parseWebVttTimestamp, + MPEGTS: (v) => +v +}; +function parseTimestampMapBody(body) { + return Object.fromEntries(body.split(",").map((kvStr) => { + const [k, v] = kvStr.split(/:(.*)/).map((kOrV) => kOrV.trim()); + return [k?.toLowerCase(), TimeStampMapParserMap[k](v)]; + })); +} +/** Seconds-per-unit for the `[HH:]MM:SS.mmm` parts, right-aligned so a missing HH just drops the leading weight. */ +const VTT_TIMESTAMP_WEIGHTS = [ + 3600, + 60, + 1, + .001 +]; +function parseWebVttTimestamp(value) { + const parts = value.split(/[:.]/); + return parts.reduce((acc, val, i) => acc + +val * (VTT_TIMESTAMP_WEIGHTS[i + 4 - parts.length] ?? 0), 0); +} +//#endregion +export { parseVttTimestampMap }; + +//# sourceMappingURL=parse-vtt-timestamp-map.js.map \ No newline at end of file diff --git a/dist/default/media/text/parse-vtt-timestamp-map.js.map b/dist/default/media/text/parse-vtt-timestamp-map.js.map new file mode 100644 index 00000000..d80f3dec --- /dev/null +++ b/dist/default/media/text/parse-vtt-timestamp-map.js.map @@ -0,0 +1 @@ +{"version":3,"file":"parse-vtt-timestamp-map.js","names":[],"sources":["../../../../src/media/text/parse-vtt-timestamp-map.ts"],"sourcesContent":["/**\n * WebVTT-in-HLS `X-TIMESTAMP-MAP`: correlates a cue's LOCAL (in-file) time with\n * an MPEG-2 presentation timestamp, so LOCAL-authored cues can be placed on the\n * media presentation timeline. Stored raw — the LOCAL→native correction is\n * `mpegts / 90000 - local` — so it stays independent of how (or whether) the\n * presentation is later re-origined. See the HLS spec, RFC 8216bis §3.5.\n */\nexport interface TimestampMap {\n /** The MPEG-2 presentation timestamp, in 90 kHz ticks, as authored. */\n mpegts: number;\n /** The LOCAL cue time the `mpegts` value maps to, in seconds. */\n local: number;\n}\n\nconst TIMESTAMP_MAP_PREFIX = 'X-TIMESTAMP-MAP=';\n\n/**\n * Scrape a WebVTT segment's `X-TIMESTAMP-MAP` header into a {@link TimestampMap}\n * — the only header line we need to correlate LOCAL cue times with the media\n * presentation timeline. Deliberately *not* a WebVTT parser: cue parsing stays\n * with the browser's native `` parser (which drops this line); this reads\n * just the one header field the native path discards.\n *\n * Returns `undefined` when the segment carries no map (e.g. cues already in\n * absolute presentation time) — per the HLS spec that means LOCAL 0 maps to\n * MPEGTS 0. Tolerant of attribute order and `[HH:]MM:SS.mmm` LOCAL forms.\n */\nexport function parseVttTimestampMap(text: string): TimestampMap | undefined {\n const timestampMapLine = text.split(/\\r\\n|\\r|\\n/).find((line) => line.startsWith(TIMESTAMP_MAP_PREFIX));\n return timestampMapLine ? parseTimestampMapBody(timestampMapLine.slice(TIMESTAMP_MAP_PREFIX.length)) : undefined;\n}\n\nconst TimeStampMapParserMap = {\n LOCAL: parseWebVttTimestamp,\n MPEGTS: (v: string) => +v,\n} as const;\n\ntype TimeStampMapParserMap = typeof TimeStampMapParserMap;\n\nfunction parseTimestampMapBody(body: string): TimestampMap | undefined {\n return Object.fromEntries(\n body.split(',').map((kvStr) => {\n const [k, v] = kvStr.split(/:(.*)/).map((kOrV) => kOrV.trim());\n return [k?.toLowerCase(), TimeStampMapParserMap[k as keyof TimeStampMapParserMap](v as string)];\n })\n ) as TimestampMap;\n}\n\n/** Seconds-per-unit for the `[HH:]MM:SS.mmm` parts, right-aligned so a missing HH just drops the leading weight. */\nconst VTT_TIMESTAMP_WEIGHTS = [3600, 60, 1, 0.001];\n\nfunction parseWebVttTimestamp(value: string): number {\n const parts = value.split(/[:.]/);\n return parts.reduce((acc, val, i) => acc + +val * (VTT_TIMESTAMP_WEIGHTS[i + 4 - parts.length] ?? 0), 0);\n}\n"],"mappings":";AAcA,MAAM,uBAAuB;;;;;;;;;;;;AAa7B,SAAgB,qBAAqB,MAAwC;CAC3E,MAAM,mBAAmB,KAAK,MAAM,YAAY,CAAC,CAAC,MAAM,SAAS,KAAK,WAAW,oBAAoB,CAAC;CACtG,OAAO,mBAAmB,sBAAsB,iBAAiB,MAAM,EAA2B,CAAC,IAAI,KAAA;AACzG;AAEA,MAAM,wBAAwB;CAC5B,OAAO;CACP,SAAS,MAAc,CAAC;AAC1B;AAIA,SAAS,sBAAsB,MAAwC;CACrE,OAAO,OAAO,YACZ,KAAK,MAAM,GAAG,CAAC,CAAC,KAAK,UAAU;EAC7B,MAAM,CAAC,GAAG,KAAK,MAAM,MAAM,OAAO,CAAC,CAAC,KAAK,SAAS,KAAK,KAAK,CAAC;EAC7D,OAAO,CAAC,GAAG,YAAY,GAAG,sBAAsB,EAAiC,CAAC,CAAW,CAAC;CAChG,CAAC,CACH;AACF;;AAGA,MAAM,wBAAwB;CAAC;CAAM;CAAI;CAAG;AAAK;AAEjD,SAAS,qBAAqB,OAAuB;CACnD,MAAM,QAAQ,MAAM,MAAM,MAAM;CAChC,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,MAAM,CAAC,OAAO,sBAAsB,IAAI,IAAI,MAAM,WAAW,IAAI,CAAC;AACzG"} \ No newline at end of file diff --git a/dist/default/media/text/resolve-vtt-metadata.js b/dist/default/media/text/resolve-vtt-metadata.js new file mode 100644 index 00000000..46704086 --- /dev/null +++ b/dist/default/media/text/resolve-vtt-metadata.js @@ -0,0 +1,23 @@ +import { parseVttTimestampMap } from "./parse-vtt-timestamp-map.js"; +//#region src/media/text/resolve-vtt-metadata.ts +/** +* Header-level text-segment metadata — the `X-TIMESTAMP-MAP` correlation scraped from +* a VTT segment's raw bytes. DOM-free (a plain fetch + regex parse): the native +* `` parser in `media/dom/text` (`resolveVttSegment`) discards this header, so a +* caller that needs it (e.g. non-zero-PTS relocation) fetches the bytes itself. +*/ +/** +* Fetch a VTT segment and scrape only its header metadata (no cue parsing). +* +* The native `` parser (`media/dom/text`'s `resolveVttSegment`) discards +* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate, +* caller-controlled fetch — the caller decides *when* metadata is needed (e.g. +* once per source) rather than paying for it on every segment. +*/ +async function resolveVttSegmentMetadata(url) { + return { timestampMap: parseVttTimestampMap(await fetch(url).then((response) => response.text())) }; +} +//#endregion +export { resolveVttSegmentMetadata }; + +//# sourceMappingURL=resolve-vtt-metadata.js.map \ No newline at end of file diff --git a/dist/default/media/text/resolve-vtt-metadata.js.map b/dist/default/media/text/resolve-vtt-metadata.js.map new file mode 100644 index 00000000..9f8fadb9 --- /dev/null +++ b/dist/default/media/text/resolve-vtt-metadata.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resolve-vtt-metadata.js","names":[],"sources":["../../../../src/media/text/resolve-vtt-metadata.ts"],"sourcesContent":["/**\n * Header-level text-segment metadata — the `X-TIMESTAMP-MAP` correlation scraped from\n * a VTT segment's raw bytes. DOM-free (a plain fetch + regex parse): the native\n * `` parser in `media/dom/text` (`resolveVttSegment`) discards this header, so a\n * caller that needs it (e.g. non-zero-PTS relocation) fetches the bytes itself.\n */\nimport { parseVttTimestampMap, type TimestampMap } from './parse-vtt-timestamp-map';\n\n/**\n * Header-level metadata for a text segment, surfaced alongside its cues. Each\n * field is present only when the segment declared it.\n */\nexport interface TextSegmentMetadata {\n timestampMap?: TimestampMap;\n}\n\n/**\n * Fetch a VTT segment and scrape only its header metadata (no cue parsing).\n *\n * The native `` parser (`media/dom/text`'s `resolveVttSegment`) discards\n * `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,\n * caller-controlled fetch — the caller decides *when* metadata is needed (e.g.\n * once per source) rather than paying for it on every segment.\n */\nexport async function resolveVttSegmentMetadata(url: string): Promise {\n const text = await fetch(url).then((response) => response.text());\n return { timestampMap: parseVttTimestampMap(text) };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAwBA,eAAsB,0BAA0B,KAA2C;CAEzF,OAAO,EAAE,cAAc,qBAAqB,MADzB,MAAM,GAAG,CAAC,CAAC,MAAM,aAAa,SAAS,KAAK,CAAC,CAChB,EAAE;AACpD"} \ No newline at end of file diff --git a/dist/default/media/types/index.js b/dist/default/media/types/index.js new file mode 100644 index 00000000..f4ad9d9f --- /dev/null +++ b/dist/default/media/types/index.js @@ -0,0 +1,36 @@ +//#region src/media/types/index.ts +/** +* Floating-point tolerance for matching segments by `startTime`. Two +* segments are considered the same position when +* `Math.abs(a.startTime - b.startTime) < SEGMENT_TIME_EPSILON`. Used by +* the source-buffer dedup and segment-loader quality-aware filter to +* tolerate sub-millisecond drift in segment timestamps across multiple +* playlists / quality levels. +*/ +const SEGMENT_TIME_EPSILON = 1e-4; +function isResolvedTrack(track) { + return "segments" in track; +} +/** +* Check if a presentation has duration (at least one track resolved). +* Narrows type to include required duration. +*/ +function hasPresentationDuration(presentation) { + return presentation.duration !== void 0; +} +/** +* Narrows a `MaybeResolvedPresentation` to a fully resolved `Presentation`. +* +* A presentation is resolved once `resolvePresentation` has parsed the +* manifest and populated both `id` and `selectionSets`. Both must be +* present — a partial value with only one of them isn't usable, and +* letting it through would have downstream behaviors crash when they +* access `selectionSets`. +*/ +function isResolvedPresentation(presentation) { + return presentation !== void 0 && presentation.id !== void 0 && presentation.selectionSets !== void 0; +} +//#endregion +export { SEGMENT_TIME_EPSILON, hasPresentationDuration, isResolvedPresentation, isResolvedTrack }; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/default/media/types/index.js.map b/dist/default/media/types/index.js.map new file mode 100644 index 00000000..ab865a50 --- /dev/null +++ b/dist/default/media/types/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","names":[],"sources":["../../../../src/media/types/index.ts"],"sourcesContent":["/**\n * Core SPF Types\n *\n * Based on CMAF-HAM (Common Media Application Format - Hypothetical Application Model)\n * Protocol-agnostic representation of streaming media content.\n *\n * @see https://github.com/AcademySoftwareFoundation/common-media-library\n */\n\n// =============================================================================\n// Base Types\n// =============================================================================\n\n/**\n * Base identifier type for all HAM objects.\n */\nexport interface Ham {\n id: string;\n}\n\n/**\n * Addressable resource with optional byte range.\n */\nexport interface AddressableObject {\n url: string;\n byteRange?: {\n start: number;\n end: number;\n };\n}\n\n// =============================================================================\n// Platform-agnostic Media Element\n// =============================================================================\n\n/**\n * Platform-agnostic media element interface.\n * Captures minimal shape needed for orchestration without DOM dependencies.\n * HTMLMediaElement satisfies this interface.\n */\nexport interface MediaElementLike {\n preload: string;\n}\n\n// =============================================================================\n// Time and Duration\n// =============================================================================\n\n/**\n * Time span with start time and duration.\n * Used for segments and other timed ranges.\n */\nexport interface TimeSpan {\n startTime: number;\n duration: number;\n}\n\n// =============================================================================\n// Enums\n// =============================================================================\n\n/**\n * Track content type.\n */\nexport type TrackType = 'video' | 'audio' | 'text';\n\n// =============================================================================\n// Frame Rate\n// =============================================================================\n\n/**\n * Video frame rate expressed as numerator/denominator.\n *\n * Examples:\n * - 30 fps: { frameRateNumerator: 30 }\n * - 29.97 fps: { frameRateNumerator: 30000, frameRateDenominator: 1001 }\n */\nexport interface FrameRate {\n frameRateNumerator: number;\n frameRateDenominator?: number;\n}\n\n// =============================================================================\n// Partially Resolved Tracks (before media playlist is fetched)\n// =============================================================================\n\n/**\n * Generic type for partially resolved tracks.\n * Removes fields that come from media playlist parsing.\n *\n * @param T - Track type to make partially resolved (must extend Track)\n */\nexport type PartiallyResolved = Omit & {\n segments?: never;\n duration?: never;\n startTime?: never;\n initialization?: never;\n};\n\n/**\n * Partially resolved video track from multivariant playlist.\n * Has metadata but no segments or initialization yet (media playlist not fetched).\n */\nexport type PartiallyResolvedVideoTrack = PartiallyResolved;\n\n/**\n * Partially resolved audio track from multivariant playlist.\n * Has metadata but no segments or initialization yet (media playlist not fetched).\n */\nexport type PartiallyResolvedAudioTrack = PartiallyResolved;\n\n// =============================================================================\n// Resolved Track Types (with segments from media playlist)\n// =============================================================================\n\n/**\n * Base track type containing common properties for all resolved tracks.\n * A resolved track has segments, duration, and initialization data.\n * All URLs are fully qualified (parsers resolve relative URLs).\n */\n/**\n * Track startTime is always 0 (for future multi-period support).\n */\nexport type Track = Ham &\n AddressableObject &\n TimeSpan & {\n type: TrackType;\n codecs?: string[]; // Optional per HLS spec\n mimeType: string;\n language?: string | undefined;\n bandwidth: number;\n initialization?: AddressableObject;\n segments: Segment[];\n /**\n * Media-timeline (decode/encode) coordinate of the track's timeline origin\n * (`startTime`) — the media-time base value of the coordinate model, peer to\n * `startTime` (presentation). Derived from the container\n * (`tfdt.baseMediaDecodeTime ÷ mdhd.timescale`); the relocation offset is\n * `startTime − startMediaTime`, never stored.\n *\n * Optional: absent until established (0-PTS sources never set it — their\n * origin is already 0). Established once per source by the\n * `establishStartMediaTime` reactor. See\n * `internal/design/spf/presentation-timeline-model.md`.\n */\n startMediaTime?: number;\n };\n\n/**\n * Per-track-type origin-establishment data, accumulated across appends (the media\n * track's `track_id` + `mdhd` timescale from the init, `tfdt` baseMediaDecodeTime of\n * that same track from the first media segment) — hence optional. The transient input\n * the `establishStartMediaTime` reactor reduces into `Track.startMediaTime`.\n *\n * `trackId` is the ISO-BMFF `track_ID` of the buffered media track (`vide`/`soun`),\n * read from the init's `tkhd`; it ties the timescale to the *same* track's\n * `baseMediaDecodeTime` (matched via `tfhd.track_id`) so a muxed segment carrying a\n * second track (e.g. `clcp` captions) reads the right `tfdt` rather than the first one.\n *\n * `segmentStartTime` is the 0-based presentation start of the segment\n * `baseMediaDecodeTime` was read from — *not* a container value (it's the playlist\n * position), but co-located because the origin is `baseMediaDecodeTime/timescale −\n * segmentStartTime`: the first *loaded* segment isn't necessarily the 0th (a\n * non-zero initial `currentTime`, or live/DVR), so the decode time alone isn't the\n * stream origin.\n */\nexport interface MediaContainerData {\n trackId?: number;\n timescale?: number;\n baseMediaDecodeTime?: number;\n segmentStartTime?: number;\n}\n\n/**\n * Raw media-segment bytes — a complete buffer or a byte stream. The transport-neutral\n * payload the loader pipeline carries; `AppendData` (the MSE `SourceBuffer` append\n * input in `media/dom/mse`) is an alias of this at the DOM boundary.\n */\nexport type SegmentData = ArrayBuffer | AsyncIterable;\n\n/**\n * Resolved video track with segments.\n */\nexport type VideoTrack = Track &\n Required> & {\n type: 'video';\n\n // Optional metadata from multivariant (per HLS spec)\n width?: number;\n height?: number;\n frameRate?: FrameRate;\n /**\n * Audio groups (`EXT-X-STREAM-INF:AUDIO`) this video rendition can pair\n * with. A list because one rendition is typically listed across multiple\n * `EXT-X-STREAM-INF` entries — one per audio group (the HLS cross-product) —\n * which the parser collapses into a single track carrying every group it\n * advertised.\n */\n audioGroupIds?: string[];\n };\n\n/**\n * Resolved audio track with segments.\n */\nexport type AudioTrack = Track &\n Required> & {\n type: 'audio';\n groupId: string;\n name: string;\n sampleRate: number;\n channels: number;\n default?: boolean;\n autoselect?: boolean;\n };\n\n/**\n * Resolved text track with segments.\n */\nexport type TextTrack = Track & {\n type: 'text';\n groupId: string;\n label: string;\n kind: 'subtitles' | 'captions';\n default?: boolean;\n autoselect?: boolean;\n forced?: boolean;\n};\n\n/**\n * Predicate that answers \"can this environment decode this track?\" — the\n * capability-probing surface, read by the track-switching hard-constraint\n * pre-pass (`excludeUnplayableTracks`) to drop undecodable renditions before\n * selection. Kept DOM-free here (a plain function type over a minimal track\n * shape) so DOM-free behaviors can consume it; the DOM implementation\n * (`canPlayTrack` in `media/dom/capabilities.ts`) wraps\n * `MediaSource.isTypeSupported`.\n *\n * Takes the minimal codec-bearing shape both video and audio candidates\n * carry. `mimeType` is optional so unprobeable candidates (no MIME) can be\n * passed straight through as playable rather than dropped.\n */\nexport type CanPlayTrack = (track: { mimeType?: string; codecs?: string[] }) => boolean;\n\n/**\n * Minimal text-track cue shape — start time, end time, and display text.\n *\n * Host-agnostic representation. `VTTCue` structurally satisfies this\n * interface, so DOM consumers pass `VTTCue` values directly. Non-DOM\n * hosts (workers, test fakes, non-browser engines) can satisfy the same\n * shape without pulling in DOM types.\n */\nexport interface Cue {\n startTime: number;\n endTime: number;\n text: string;\n}\n\n/**\n * Media element with an iterable text-track list, host-agnostic.\n *\n * Extends `MediaElementLike` with the minimum surface needed to observe\n * which text tracks are currently mounted on the media. `HTMLMediaElement`\n * structurally satisfies this (its `textTracks` is a `TextTrackList`,\n * which is iterable with `{ id }` items).\n */\nexport interface MediaElementWithTextTracks extends MediaElementLike {\n readonly textTracks: Iterable<{ readonly id: string }>;\n}\n\n/**\n * Partially resolved text track from multivariant playlist.\n * Has metadata but no segments or initialization yet (media playlist not fetched).\n */\nexport type PartiallyResolvedTextTrack = PartiallyResolved;\n\n/**\n * Union of all resolved track types.\n */\nexport type ResolvedTrack = VideoTrack | AudioTrack | TextTrack;\n\n/**\n * Union of all partially resolved track types.\n */\nexport type PartiallyResolvedTrack =\n | PartiallyResolvedVideoTrack\n | PartiallyResolvedAudioTrack\n | PartiallyResolvedTextTrack;\n\n// =============================================================================\n// Switching and Selection Sets\n// =============================================================================\n\n/**\n * Generic switching set type.\n * A group of tracks that can be switched between seamlessly.\n *\n * @param T - Track type (VideoTrack, AudioTrack, or TextTrack)\n */\nexport type SwitchingSetOf = Ham & {\n type: T['type'];\n tracks: (PartiallyResolved | T)[];\n};\n\n/**\n * Video switching set - contains only video tracks (partially resolved or fully resolved).\n */\nexport type VideoSwitchingSet = SwitchingSetOf;\n\n/**\n * Audio switching set - contains only audio tracks (partially resolved or fully resolved).\n */\nexport type AudioSwitchingSet = SwitchingSetOf;\n\n/**\n * Text switching set - contains only text tracks (partially resolved or fully resolved).\n */\nexport type TextSwitchingSet = SwitchingSetOf;\n\n/**\n * Switching set - a group of tracks that can be switched between seamlessly.\n * Discriminated by track type.\n */\nexport type SwitchingSet = VideoSwitchingSet | AudioSwitchingSet | TextSwitchingSet;\n\n/**\n * Generic selection set type.\n * Groups switching sets by track type.\n *\n * @param T - Track type (VideoTrack, AudioTrack, or TextTrack)\n */\nexport type SelectionSetOf = Ham & {\n type: T['type'];\n switchingSets: SwitchingSetOf[];\n};\n\n/**\n * Video selection set - contains only video switching sets.\n */\nexport type VideoSelectionSet = SelectionSetOf;\n\n/**\n * Audio selection set - contains only audio switching sets.\n */\nexport type AudioSelectionSet = SelectionSetOf;\n\n/**\n * Text selection set - contains only text switching sets.\n */\nexport type TextSelectionSet = SelectionSetOf;\n\n/**\n * Selection set - groups switching sets by track type.\n * Discriminated union ensures type-safe track access.\n */\nexport type SelectionSet = VideoSelectionSet | AudioSelectionSet | TextSelectionSet;\n\n// =============================================================================\n// Segment\n// =============================================================================\n\n/**\n * Media segment with timing information.\n * Follows CMAF-HAM composition pattern.\n */\nexport type Segment = Ham & AddressableObject & TimeSpan;\n\n/**\n * Floating-point tolerance for matching segments by `startTime`. Two\n * segments are considered the same position when\n * `Math.abs(a.startTime - b.startTime) < SEGMENT_TIME_EPSILON`. Used by\n * the source-buffer dedup and segment-loader quality-aware filter to\n * tolerate sub-millisecond drift in segment timestamps across multiple\n * playlists / quality levels.\n */\nexport const SEGMENT_TIME_EPSILON = 0.0001;\n\n// =============================================================================\n// Media Playlist Info\n// =============================================================================\n\n/**\n * Intermediate representation of a parsed media playlist.\n * Used internally before assembling into full Track structure.\n */\nexport interface MediaPlaylistInfo {\n version: number;\n targetDuration: number;\n playlistType: 'VOD' | 'EVENT' | undefined;\n initSegment: AddressableObject | null;\n segments: Segment[];\n duration: number;\n endList: boolean;\n}\n\n// =============================================================================\n// Presentation\n// =============================================================================\n\n/**\n * Presentation - a single playable period of content.\n * Uses TimeSpan fields (startTime always 0, duration optional until track resolved).\n *\n * Extends AddressableObject so `url` contains the original manifest URL.\n * All URLs are fully qualified (parsers resolve relative URLs).\n */\nexport type Presentation = Ham &\n AddressableObject &\n Partial & {\n selectionSets: SelectionSet[];\n };\n\n/**\n * State-shaped presentation that may or may not be resolved yet.\n *\n * The lifecycle is a single value: a caller writes `{ url }`, and the\n * resolver populates the rest in place. `url` is always present; resolved\n * fields (`id`, `selectionSets`, duration) appear once parsing succeeds.\n *\n * Use `isResolvedPresentation` to narrow to `Presentation`.\n */\nexport type MaybeResolvedPresentation = AddressableObject & Partial>;\n\n// =============================================================================\n// Type Guards\n// =============================================================================\n\n/**\n * Check if a track is resolved (has segments).\n * Works for all track types with overloaded signatures for type narrowing.\n */\nexport function isResolvedTrack(track: PartiallyResolvedVideoTrack | VideoTrack): track is VideoTrack;\nexport function isResolvedTrack(track: PartiallyResolvedAudioTrack | AudioTrack): track is AudioTrack;\nexport function isResolvedTrack(track: PartiallyResolvedTextTrack | TextTrack): track is TextTrack;\nexport function isResolvedTrack(track: PartiallyResolvedTrack | ResolvedTrack): track is ResolvedTrack;\nexport function isResolvedTrack(track: PartiallyResolvedTrack | ResolvedTrack): track is ResolvedTrack {\n return 'segments' in track;\n}\n\n/**\n * Check if a presentation has duration (at least one track resolved).\n * Narrows type to include required duration.\n */\nexport function hasPresentationDuration(\n presentation: MaybeResolvedPresentation\n): presentation is MaybeResolvedPresentation & { duration: number } {\n return presentation.duration !== undefined;\n}\n\n/**\n * Narrows a `MaybeResolvedPresentation` to a fully resolved `Presentation`.\n *\n * A presentation is resolved once `resolvePresentation` has parsed the\n * manifest and populated both `id` and `selectionSets`. Both must be\n * present — a partial value with only one of them isn't usable, and\n * letting it through would have downstream behaviors crash when they\n * access `selectionSets`.\n */\nexport function isResolvedPresentation(\n presentation: MaybeResolvedPresentation | undefined\n): presentation is Presentation {\n return presentation !== undefined && presentation.id !== undefined && presentation.selectionSets !== undefined;\n}\n"],"mappings":";;;;;;;;;AAsXA,MAAa,uBAAuB;AA4DpC,SAAgB,gBAAgB,OAAuE;CACrG,OAAO,cAAc;AACvB;;;;;AAMA,SAAgB,wBACd,cACkE;CAClE,OAAO,aAAa,aAAa,KAAA;AACnC;;;;;;;;;;AAWA,SAAgB,uBACd,cAC8B;CAC9B,OAAO,iBAAiB,KAAA,KAAa,aAAa,OAAO,KAAA,KAAa,aAAa,kBAAkB,KAAA;AACvG"} \ No newline at end of file diff --git a/dist/default/media/utils/cdn.js b/dist/default/media/utils/cdn.js new file mode 100644 index 00000000..27a38e69 --- /dev/null +++ b/dist/default/media/utils/cdn.js @@ -0,0 +1,56 @@ +//#region src/media/utils/cdn.ts +/** +* Default {@link GetCdnId}: the URL's origin (scheme + host + port); falls back +* to the raw string when the URL can't be parsed, so the return value is always +* a stable grouping key. +*/ +function getCdnId(url) { + try { + return new URL(url).origin; + } catch { + return url; + } +} +const CDN_TYPE_PRIORITY = { + video: 0, + audio: 1, + text: 2 +}; +/** +* The distinct CDNs a presentation's tracks are served from, ordered video CDNs +* first, then audio, then text (manifest order within a type). The head is the +* primary CDN — the one a sticky pick defaults to — and is always video-derived +* when the source has video. Returns `[]` for an unresolved presentation with +* no tracks. +* +* Redundant-stream sources list the same content on multiple hosts (e.g. Mux's +* `?redundant_streams=true`), so each host contributes its own candidate tracks; +* this collapses them to the set of CDNs across every track type. The CDN-id +* derivation defaults to {@link getCdnId}; pass a consumer-configured `getId` to +* key on something other than origin. +*/ +function getOrderedCdnIds(presentation, getId = getCdnId) { + const seen = /* @__PURE__ */ new Set(); + const ids = []; + const selectionSets = [...presentation.selectionSets ?? []].sort((a, b) => CDN_TYPE_PRIORITY[a.type] - CDN_TYPE_PRIORITY[b.type]); + for (const selectionSet of selectionSets) for (const switchingSet of selectionSet.switchingSets) for (const track of switchingSet.tracks) { + const id = getId(track.url); + if (seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} +/** +* Add a CDN id to a failed-CDN list, preserving order and ignoring duplicates. +* Idempotent: re-adding an already-present id returns the same array reference +* (so a no-op trip doesn't churn the `failedCdns` signal). The failover trip in +* `resolve-track` and the segment loaders feed this into `failedCdns` via `update`. +*/ +function addFailedCdn(failed, cdn) { + return failed?.includes(cdn) ? failed : [...failed ?? [], cdn]; +} +//#endregion +export { addFailedCdn, getCdnId, getOrderedCdnIds }; + +//# sourceMappingURL=cdn.js.map \ No newline at end of file diff --git a/dist/default/media/utils/cdn.js.map b/dist/default/media/utils/cdn.js.map new file mode 100644 index 00000000..b2262370 --- /dev/null +++ b/dist/default/media/utils/cdn.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cdn.js","names":[],"sources":["../../../../src/media/utils/cdn.ts"],"sourcesContent":["import type { MaybeResolvedPresentation, TrackType } from '../types';\n\n/**\n * Derive a stable grouping key for the CDN a URL is served from. Synchronous and\n * pure (deliberately not a `resolve*` — no fetch). Consumers override the\n * default via the engine's `getCdnId` config (e.g. to key on Mux's `cdn=` query\n * param instead of the host); every CDN-identity site reads that same function\n * so keys stay comparable across `cdnPriority`, `failedCdns`, and the\n * track-switching constraint + scope.\n */\nexport type GetCdnId = (url: string) => string;\n\n/**\n * Default {@link GetCdnId}: the URL's origin (scheme + host + port); falls back\n * to the raw string when the URL can't be parsed, so the return value is always\n * a stable grouping key.\n */\nexport function getCdnId(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return url;\n }\n}\n\n// Track-type priority for CDN ordering: video first, then audio, then text.\n// Selection sets are visited in this order so the head of the returned list is\n// always video-derived. `preferActiveCdn` anchors every track type to the\n// first CDN with surviving tracks (the head), so this makes \"the primary CDN\n// is the video CDN\" a guarantee of `getOrderedCdnIds` rather than a side effect\n// of the order tracks happen to be parsed in.\nconst CDN_TYPE_PRIORITY: Record = { video: 0, audio: 1, text: 2 };\n\n/**\n * The distinct CDNs a presentation's tracks are served from, ordered video CDNs\n * first, then audio, then text (manifest order within a type). The head is the\n * primary CDN — the one a sticky pick defaults to — and is always video-derived\n * when the source has video. Returns `[]` for an unresolved presentation with\n * no tracks.\n *\n * Redundant-stream sources list the same content on multiple hosts (e.g. Mux's\n * `?redundant_streams=true`), so each host contributes its own candidate tracks;\n * this collapses them to the set of CDNs across every track type. The CDN-id\n * derivation defaults to {@link getCdnId}; pass a consumer-configured `getId` to\n * key on something other than origin.\n */\nexport function getOrderedCdnIds(presentation: MaybeResolvedPresentation, getId: GetCdnId = getCdnId): string[] {\n const seen = new Set();\n const ids: string[] = [];\n // Stable sort keeps manifest order among same-type selection sets.\n const selectionSets = [...(presentation.selectionSets ?? [])].sort(\n (a, b) => CDN_TYPE_PRIORITY[a.type] - CDN_TYPE_PRIORITY[b.type]\n );\n for (const selectionSet of selectionSets) {\n for (const switchingSet of selectionSet.switchingSets) {\n for (const track of switchingSet.tracks) {\n const id = getId(track.url);\n if (seen.has(id)) continue;\n seen.add(id);\n ids.push(id);\n }\n }\n }\n return ids;\n}\n\n/**\n * Add a CDN id to a failed-CDN list, preserving order and ignoring duplicates.\n * Idempotent: re-adding an already-present id returns the same array reference\n * (so a no-op trip doesn't churn the `failedCdns` signal). The failover trip in\n * `resolve-track` and the segment loaders feed this into `failedCdns` via `update`.\n */\nexport function addFailedCdn(failed: string[] | undefined, cdn: string): string[] {\n return failed?.includes(cdn) ? failed : [...(failed ?? []), cdn];\n}\n"],"mappings":";;;;;;AAiBA,SAAgB,SAAS,KAAqB;CAC5C,IAAI;EACF,OAAO,IAAI,IAAI,GAAG,CAAC,CAAC;CACtB,QAAQ;EACN,OAAO;CACT;AACF;AAQA,MAAM,oBAA+C;CAAE,OAAO;CAAG,OAAO;CAAG,MAAM;AAAE;;;;;;;;;;;;;;AAenF,SAAgB,iBAAiB,cAAyC,QAAkB,UAAoB;CAC9G,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,MAAgB,CAAC;CAEvB,MAAM,gBAAgB,CAAC,GAAI,aAAa,iBAAiB,CAAC,CAAE,CAAC,CAAC,MAC3D,GAAG,MAAM,kBAAkB,EAAE,QAAQ,kBAAkB,EAAE,KAC5D;CACA,KAAK,MAAM,gBAAgB,eACzB,KAAK,MAAM,gBAAgB,aAAa,eACtC,KAAK,MAAM,SAAS,aAAa,QAAQ;EACvC,MAAM,KAAK,MAAM,MAAM,GAAG;EAC1B,IAAI,KAAK,IAAI,EAAE,GAAG;EAClB,KAAK,IAAI,EAAE;EACX,IAAI,KAAK,EAAE;CACb;CAGJ,OAAO;AACT;;;;;;;AAQA,SAAgB,aAAa,QAA8B,KAAuB;CAChF,OAAO,QAAQ,SAAS,GAAG,IAAI,SAAS,CAAC,GAAI,UAAU,CAAC,GAAI,GAAG;AACjE"} \ No newline at end of file diff --git a/dist/default/media/utils/preload.js b/dist/default/media/utils/preload.js new file mode 100644 index 00000000..64dad834 --- /dev/null +++ b/dist/default/media/utils/preload.js @@ -0,0 +1,22 @@ +//#region src/media/utils/preload.ts +function isStandardPreload(value) { + return value === "auto" || value === "metadata" || value === "none"; +} +/** +* Default `preload` value used as the fallback across behaviors +* (`syncPreload`, `resolvePresentation`, `isBlockingPreload`). Matches the +* `