@videojs/spf 10.0.0-beta.15 → 10.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/default/core/buffer/forward-buffer.js.map +1 -1
  2. package/dist/default/core/create-machine-actor.js +91 -0
  3. package/dist/default/core/create-machine-actor.js.map +1 -0
  4. package/dist/default/core/create-machine-reactor.js +83 -0
  5. package/dist/default/core/create-machine-reactor.js.map +1 -0
  6. package/dist/default/core/create-transition-actor.js +46 -0
  7. package/dist/default/core/create-transition-actor.js.map +1 -0
  8. package/dist/default/core/features/resolve-presentation.js +42 -46
  9. package/dist/default/core/features/resolve-presentation.js.map +1 -1
  10. package/dist/default/core/features/resolve-track.js +1 -1
  11. package/dist/default/core/machine.js +26 -0
  12. package/dist/default/core/machine.js.map +1 -0
  13. package/dist/default/core/signals/primitives.js +1 -1
  14. package/dist/default/core/task.js +76 -7
  15. package/dist/default/core/task.js.map +1 -1
  16. package/dist/default/core/types/index.js.map +1 -1
  17. package/dist/default/dom/features/end-of-stream.js +5 -5
  18. package/dist/default/dom/features/end-of-stream.js.map +1 -1
  19. package/dist/default/dom/features/load-text-track-cues.js +80 -137
  20. package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
  21. package/dist/default/dom/features/segment-loader-actor.js +150 -103
  22. package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
  23. package/dist/default/dom/features/sync-text-tracks.js +95 -0
  24. package/dist/default/dom/features/sync-text-tracks.js.map +1 -0
  25. package/dist/default/dom/features/text-track-segment-loader-actor.js +55 -0
  26. package/dist/default/dom/features/text-track-segment-loader-actor.js.map +1 -0
  27. package/dist/default/dom/features/text-tracks-actor.js +42 -0
  28. package/dist/default/dom/features/text-tracks-actor.js.map +1 -0
  29. package/dist/default/dom/features/track-playback-initiated.js +51 -38
  30. package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
  31. package/dist/default/dom/media/source-buffer-actor.js +52 -91
  32. package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
  33. package/dist/default/dom/network/fetch.js +0 -1
  34. package/dist/default/dom/network/fetch.js.map +1 -1
  35. package/dist/default/dom/playback-engine/engine.js +4 -13
  36. package/dist/default/dom/playback-engine/engine.js.map +1 -1
  37. package/dist/dev/core/actor.d.ts +19 -10
  38. package/dist/dev/core/actor.d.ts.map +1 -1
  39. package/dist/dev/core/buffer/forward-buffer.js.map +1 -1
  40. package/dist/dev/core/create-machine-actor.d.ts +12 -0
  41. package/dist/dev/core/create-machine-actor.d.ts.map +1 -0
  42. package/dist/dev/core/create-machine-actor.js +91 -0
  43. package/dist/dev/core/create-machine-actor.js.map +1 -0
  44. package/dist/dev/core/create-machine-reactor.d.ts +8 -0
  45. package/dist/dev/core/create-machine-reactor.d.ts.map +1 -0
  46. package/dist/dev/core/create-machine-reactor.js +83 -0
  47. package/dist/dev/core/create-machine-reactor.js.map +1 -0
  48. package/dist/dev/core/create-transition-actor.d.ts +23 -0
  49. package/dist/dev/core/create-transition-actor.d.ts.map +1 -0
  50. package/dist/dev/core/create-transition-actor.js +46 -0
  51. package/dist/dev/core/create-transition-actor.js.map +1 -0
  52. package/dist/dev/core/features/resolve-presentation.js +42 -46
  53. package/dist/dev/core/features/resolve-presentation.js.map +1 -1
  54. package/dist/dev/core/features/resolve-track.js +1 -1
  55. package/dist/dev/core/machine.d.ts +21 -0
  56. package/dist/dev/core/machine.d.ts.map +1 -0
  57. package/dist/dev/core/machine.js +26 -0
  58. package/dist/dev/core/machine.js.map +1 -0
  59. package/dist/dev/core/signals/primitives.js +1 -1
  60. package/dist/dev/core/task.js +76 -7
  61. package/dist/dev/core/task.js.map +1 -1
  62. package/dist/dev/core/types/index.d.ts +1 -8
  63. package/dist/dev/core/types/index.d.ts.map +1 -1
  64. package/dist/dev/core/types/index.js.map +1 -1
  65. package/dist/dev/dom/features/end-of-stream.js +5 -5
  66. package/dist/dev/dom/features/end-of-stream.js.map +1 -1
  67. package/dist/dev/dom/features/load-text-track-cues.d.ts +50 -26
  68. package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
  69. package/dist/dev/dom/features/load-text-track-cues.js +80 -137
  70. package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
  71. package/dist/dev/dom/features/segment-loader-actor.js +150 -103
  72. package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
  73. package/dist/dev/dom/features/sync-text-tracks.js +95 -0
  74. package/dist/dev/dom/features/sync-text-tracks.js.map +1 -0
  75. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts +12 -0
  76. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts.map +1 -0
  77. package/dist/dev/dom/features/text-track-segment-loader-actor.js +55 -0
  78. package/dist/dev/dom/features/text-track-segment-loader-actor.js.map +1 -0
  79. package/dist/dev/dom/features/text-tracks-actor.d.ts +31 -0
  80. package/dist/dev/dom/features/text-tracks-actor.d.ts.map +1 -0
  81. package/dist/dev/dom/features/text-tracks-actor.js +42 -0
  82. package/dist/dev/dom/features/text-tracks-actor.js.map +1 -0
  83. package/dist/dev/dom/features/track-playback-initiated.d.ts +11 -9
  84. package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
  85. package/dist/dev/dom/features/track-playback-initiated.js +51 -38
  86. package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
  87. package/dist/dev/dom/media/source-buffer-actor.d.ts +14 -8
  88. package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
  89. package/dist/dev/dom/media/source-buffer-actor.js +52 -91
  90. package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
  91. package/dist/dev/dom/network/fetch.js +0 -1
  92. package/dist/dev/dom/network/fetch.js.map +1 -1
  93. package/dist/dev/dom/playback-engine/engine.d.ts +4 -3
  94. package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
  95. package/dist/dev/dom/playback-engine/engine.js +4 -13
  96. package/dist/dev/dom/playback-engine/engine.js.map +1 -1
  97. package/package.json +5 -4
  98. package/dist/default/dom/features/setup-text-tracks.js +0 -67
  99. package/dist/default/dom/features/setup-text-tracks.js.map +0 -1
  100. package/dist/default/dom/features/sync-selected-text-track-from-dom.js +0 -54
  101. package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  102. package/dist/default/dom/features/sync-text-track-modes.js +0 -33
  103. package/dist/default/dom/features/sync-text-track-modes.js.map +0 -1
  104. package/dist/dev/dom/features/setup-text-tracks.js +0 -67
  105. package/dist/dev/dom/features/setup-text-tracks.js.map +0 -1
  106. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +0 -54
  107. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  108. package/dist/dev/dom/features/sync-text-track-modes.js +0 -33
  109. package/dist/dev/dom/features/sync-text-track-modes.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"task.js","names":["#runFn","#abortController","#signal","#status","#value","#error","#pending","#chain","#current"],"sources":["../../../src/core/task.ts"],"sourcesContent":["import { anyAbortSignal } from '@videojs/utils/events';\nimport { generateId } from './utils/generate-id';\n\n// =============================================================================\n// DeepReadonly\n// =============================================================================\n\n/** Recursively marks all properties as readonly. */\nexport type DeepReadonly<T> = T extends (infer U)[]\n ? ReadonlyArray<DeepReadonly<U>>\n : T extends object\n ? { readonly [K in keyof T]: DeepReadonly<T[K]> }\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<TValue = void, TError = unknown> {\n readonly id: string;\n readonly status: TaskStatus;\n readonly value: DeepReadonly<TValue> | undefined;\n readonly error: DeepReadonly<TError> | undefined;\n run(): Promise<TValue>;\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<TValue = void, TError = unknown> implements TaskLike<TValue, TError> {\n readonly id: string;\n readonly #runFn: (signal: AbortSignal) => Promise<TValue>;\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<TValue>, 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<TValue> | undefined {\n return this.#value as DeepReadonly<TValue> | undefined;\n }\n\n get error(): DeepReadonly<TError> | undefined {\n return this.#error as DeepReadonly<TError> | undefined;\n }\n\n async run(): Promise<TValue> {\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<string, TaskLike<unknown, unknown>>();\n\n schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): void {\n if (this.#pending.has(task.id)) return;\n\n this.#pending.set(task.id, task as TaskLike<unknown, unknown>);\n task\n .run()\n .catch((error) => {\n if (!(error instanceof Error && error.name === 'AbortError')) throw error;\n })\n .finally(() => {\n this.#pending.delete(task.id);\n });\n }\n\n abortAll(): void {\n for (const task of this.#pending.values()) task.abort();\n this.#pending.clear();\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<unknown> = Promise.resolve();\n readonly #pending = new Set<TaskLike<unknown, unknown>>();\n #current: TaskLike<unknown, unknown> | null = null;\n\n schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): Promise<TValue> {\n const t = task as TaskLike<unknown, unknown>;\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<TValue>;\n }\n\n abortAll(): void {\n for (const task of this.#pending) task.abort();\n this.#pending.clear();\n this.#current?.abort();\n }\n\n destroy(): void {\n this.abortAll();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgEA,IAAa,OAAb,MAAuF;CACrF;CACA;CACA,mBAA4B,IAAI,iBAAiB;CACjD;CAEA,UAAsB;CACtB,SAA6B,KAAA;CAC7B,SAA6B,KAAA;CAE7B,YAAY,OAAiD,QAAqB;AAChF,QAAA,QAAc;EACd,MAAM,QAAQ,QAAQ;AACtB,OAAK,KAAK,OAAO,UAAU,aAAa,OAAO,GAAI,SAAS,YAAY;AACxE,QAAA,SAAe,QAAQ,SACnB,eAAe,CAAC,MAAA,gBAAsB,QAAQ,OAAO,OAAO,CAAC,GAC7D,MAAA,gBAAsB;;CAG5B,IAAI,SAAqB;AACvB,SAAO,MAAA;;CAGT,IAAI,QAA0C;AAC5C,SAAO,MAAA;;CAGT,IAAI,QAA0C;AAC5C,SAAO,MAAA;;CAGT,MAAM,MAAuB;AAC3B,QAAA,SAAe;AACf,MAAI;GACF,MAAM,SAAS,MAAM,MAAA,MAAY,MAAA,OAAa;AAC9C,SAAA,QAAc;AACd,SAAA,SAAe;AACf,UAAO;WACA,GAAG;AACV,SAAA,QAAc;AACd,SAAA,SAAe;AACf,SAAM;;;CAIV,QAAc;AACZ,QAAA,gBAAsB,OAAO;;;;;;;;;;AAejC,IAAa,mBAAb,MAA8B;CAC5B,2BAAoB,IAAI,KAAyC;CAEjE,SAA0C,MAAsC;AAC9E,MAAI,MAAA,QAAc,IAAI,KAAK,GAAG,CAAE;AAEhC,QAAA,QAAc,IAAI,KAAK,IAAI,KAAmC;AAC9D,OACG,KAAK,CACL,OAAO,UAAU;AAChB,OAAI,EAAE,iBAAiB,SAAS,MAAM,SAAS,cAAe,OAAM;IACpE,CACD,cAAc;AACb,SAAA,QAAc,OAAO,KAAK,GAAG;IAC7B;;CAGN,WAAiB;AACf,OAAK,MAAM,QAAQ,MAAA,QAAc,QAAQ,CAAE,MAAK,OAAO;AACvD,QAAA,QAAc,OAAO;;;;;;;;;;;;;;;;;AAsBzB,IAAa,eAAb,MAA0B;CACxB,SAA2B,QAAQ,SAAS;CAC5C,2BAAoB,IAAI,KAAiC;CACzD,WAA8C;CAE9C,SAA0C,MAAiD;EACzF,MAAM,IAAI;AACV,QAAA,QAAc,IAAI,EAAE;EAEpB,MAAM,SAAS,MAAA,MACZ,WAAW;AACV,SAAA,QAAc,OAAO,EAAE;AACvB,SAAA,UAAgB;AAChB,UAAO,KAAK,KAAK;IACjB,CACD,cAAc;AACb,SAAA,UAAgB;IAChB;AAGJ,QAAA,QAAc,OAAO,WACb,UACA,GACP;AAED,SAAO;;CAGT,WAAiB;AACf,OAAK,MAAM,QAAQ,MAAA,QAAe,MAAK,OAAO;AAC9C,QAAA,QAAc,OAAO;AACrB,QAAA,SAAe,OAAO;;CAGxB,UAAgB;AACd,OAAK,UAAU"}
1
+ {"version":3,"file":"task.js","names":["#runFn","#abortController","#signal","#status","#value","#error","#pending","#destroyed","#settled","#resolveSettled","#chain","#current"],"sources":["../../../src/core/task.ts"],"sourcesContent":["import { anyAbortSignal } from '@videojs/utils/events';\nimport { generateId } from './utils/generate-id';\n\n// =============================================================================\n// DeepReadonly\n// =============================================================================\n\n/** Recursively marks all properties as readonly. */\nexport type DeepReadonly<T> = T extends (infer U)[]\n ? ReadonlyArray<DeepReadonly<U>>\n : T extends object\n ? { readonly [K in keyof T]: DeepReadonly<T[K]> }\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<TValue = void, TError = unknown> {\n readonly id: string;\n readonly status: TaskStatus;\n readonly value: DeepReadonly<TValue> | undefined;\n readonly error: DeepReadonly<TError> | undefined;\n run(): Promise<TValue>;\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<TValue = void, TError = unknown> implements TaskLike<TValue, TError> {\n readonly id: string;\n readonly #runFn: (signal: AbortSignal) => Promise<TValue>;\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<TValue>, 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<TValue> | undefined {\n return this.#value as DeepReadonly<TValue> | undefined;\n }\n\n get error(): DeepReadonly<TError> | undefined {\n return this.#error as DeepReadonly<TError> | undefined;\n }\n\n async run(): Promise<TValue> {\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<string, { task: TaskLike<unknown, unknown>; promise: Promise<unknown> }>();\n #settled: Promise<void> = Promise.resolve();\n #resolveSettled: (() => void) | null = null;\n #destroyed = false;\n\n schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): Promise<TValue> {\n if (this.#destroyed) return Promise.resolve() as Promise<TValue>;\n const existing = this.#pending.get(task.id);\n if (existing) return existing.promise as Promise<TValue>;\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<unknown, unknown>, promise: promise as Promise<unknown> });\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<unknown> = Promise.resolve();\n readonly #pending = new Set<TaskLike<unknown, unknown>>();\n #current: TaskLike<unknown, unknown> | null = null;\n #destroyed = false;\n\n schedule<TValue = void, TError = unknown>(task: TaskLike<TValue, TError>): Promise<TValue> {\n if (this.#destroyed) return Promise.resolve() as Promise<TValue>;\n const t = task as TaskLike<unknown, unknown>;\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<TValue>;\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<void> {\n return this.#chain as Promise<void>;\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,iBAAiB;CACjD;CAEA,UAAsB;CACtB,SAA6B,KAAA;CAC7B,SAA6B,KAAA;CAE7B,YAAY,OAAiD,QAAqB;AAChF,QAAA,QAAc;EACd,MAAM,QAAQ,QAAQ;AACtB,OAAK,KAAK,OAAO,UAAU,aAAa,OAAO,GAAI,SAAS,YAAY;AACxE,QAAA,SAAe,QAAQ,SACnB,eAAe,CAAC,MAAA,gBAAsB,QAAQ,OAAO,OAAO,CAAC,GAC7D,MAAA,gBAAsB;;CAG5B,IAAI,SAAqB;AACvB,SAAO,MAAA;;CAGT,IAAI,QAA0C;AAC5C,SAAO,MAAA;;CAGT,IAAI,QAA0C;AAC5C,SAAO,MAAA;;CAGT,MAAM,MAAuB;AAC3B,QAAA,SAAe;AACf,MAAI;GACF,MAAM,SAAS,MAAM,MAAA,MAAY,MAAA,OAAa;AAC9C,SAAA,QAAc;AACd,SAAA,SAAe;AACf,UAAO;WACA,GAAG;AACV,SAAA,QAAc;AACd,SAAA,SAAe;AACf,SAAM;;;CAIV,QAAc;AACZ,QAAA,gBAAsB,OAAO;;;;;;;;;;AAejC,IAAa,mBAAb,MAA8B;CAC5B,2BAAoB,IAAI,KAA8E;CACtG,WAA0B,QAAQ,SAAS;CAC3C,kBAAuC;CACvC,aAAa;CAEb,SAA0C,MAAiD;AACzF,MAAI,MAAA,UAAiB,QAAO,QAAQ,SAAS;EAC7C,MAAM,WAAW,MAAA,QAAc,IAAI,KAAK,GAAG;AAC3C,MAAI,SAAU,QAAO,SAAS;AAE9B,MAAI,MAAA,QAAc,SAAS,EACzB,OAAA,UAAgB,IAAI,SAAS,YAAY;AACvC,SAAA,iBAAuB;IACvB;EAGJ,MAAM,UAAU,KAAK,KAAK;AAE1B,UAAQ,YAAY,GAAG;EAEvB,MAAM,gBAAgB;AACpB,SAAA,QAAc,OAAO,KAAK,GAAG;AAC7B,OAAI,MAAA,QAAc,SAAS,GAAG;AAC5B,UAAA,kBAAwB;AACxB,UAAA,iBAAuB;;;AAG3B,UAAQ,KAAK,SAAS,QAAQ;AAE9B,QAAA,QAAc,IAAI,KAAK,IAAI;GAAQ;GAA6C;GAA6B,CAAC;AAC9G,SAAO;;;;;;;;CAST,YAAY,UAA4B;AACtC,MAAI,MAAA,QAAc,SAAS,EAAG;EAC9B,MAAM,WAAW,MAAA;AACjB,WAAS,WACD;AACJ,OAAI,MAAA,YAAkB,SAAU;AAChC,aAAU;WAEN,GACP;;CAGH,WAAiB;AACf,OAAK,MAAM,EAAE,UAAU,MAAA,QAAc,QAAQ,CAAE,MAAK,OAAO;AAC3D,QAAA,QAAc,OAAO;AAIrB,QAAA,kBAAwB;AACxB,QAAA,iBAAuB;AACvB,QAAA,UAAgB,QAAQ,SAAS;;CAGnC,UAAgB;AACd,QAAA,YAAkB;AAClB,OAAK,UAAU;;;;;;;;;;;;;;;;;AAsBnB,IAAa,eAAb,MAA0B;CACxB,SAA2B,QAAQ,SAAS;CAC5C,2BAAoB,IAAI,KAAiC;CACzD,WAA8C;CAC9C,aAAa;CAEb,SAA0C,MAAiD;AACzF,MAAI,MAAA,UAAiB,QAAO,QAAQ,SAAS;EAC7C,MAAM,IAAI;AACV,QAAA,QAAc,IAAI,EAAE;EAEpB,MAAM,SAAS,MAAA,MACZ,WAAW;AACV,SAAA,QAAc,OAAO,EAAE;AACvB,SAAA,UAAgB;AAChB,UAAO,KAAK,KAAK;IACjB,CACD,cAAc;AACb,SAAA,UAAgB;IAChB;AAGJ,QAAA,QAAc,OAAO,WACb,UACA,GACP;AAED,SAAO;;;;;;;;CAST,IAAI,UAAyB;AAC3B,SAAO,MAAA;;;;;;;;;CAUT,YAAY,UAA4B;AACtC,MAAI,MAAA,QAAc,SAAS,KAAK,MAAA,YAAkB,KAAM;EACxD,MAAM,eAAe,MAAA;AACrB,eAAa,WACL;AACJ,OAAI,MAAA,UAAgB,aAAc;AAClC,aAAU;WAEN,GACP;;;CAIH,eAAqB;AACnB,OAAK,MAAM,QAAQ,MAAA,QAAe,MAAK,OAAO;AAC9C,QAAA,QAAc,OAAO;;CAGvB,WAAiB;AACf,OAAK,cAAc;AACnB,QAAA,SAAe,OAAO;;CAGxB,UAAgB;AACd,QAAA,YAAkB;AAClB,OAAK,UAAU"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../../src/core/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<T extends Track = Track> = Omit<T, 'segments' | 'initialization' | keyof TimeSpan> & {\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<VideoTrack>;\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<AudioTrack>;\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\n/**\n * Resolved video track with segments.\n */\nexport type VideoTrack = Track &\n Required<Pick<Track, 'initialization' | 'codecs'>> & {\n type: 'video';\n\n // Optional metadata from multivariant (per HLS spec)\n width?: number;\n height?: number;\n frameRate?: FrameRate;\n audioGroupId?: string;\n };\n\n/**\n * Resolved audio track with segments.\n */\nexport type AudioTrack = Track &\n Required<Pick<Track, 'initialization' | 'codecs'>> & {\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 * 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<TextTrack>;\n\n/**\n * Unresolved text track (alias for backwards compatibility).\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 * Union of all unresolved track types (alias for backwards compatibility).\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<T extends Track = Track> = Ham & {\n type: T['type'];\n tracks: (PartiallyResolved<T> | T)[];\n};\n\n/**\n * Video switching set - contains only video tracks (partially resolved or fully resolved).\n */\nexport type VideoSwitchingSet = SwitchingSetOf<VideoTrack>;\n\n/**\n * Audio switching set - contains only audio tracks (partially resolved or fully resolved).\n */\nexport type AudioSwitchingSet = SwitchingSetOf<AudioTrack>;\n\n/**\n * Text switching set - contains only text tracks (partially resolved or fully resolved).\n */\nexport type TextSwitchingSet = SwitchingSetOf<TextTrack>;\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<T extends Track = Track> = Ham & {\n type: T['type'];\n switchingSets: SwitchingSetOf<T>[];\n};\n\n/**\n * Video selection set - contains only video switching sets.\n */\nexport type VideoSelectionSet = SelectionSetOf<VideoTrack>;\n\n/**\n * Audio selection set - contains only audio switching sets.\n */\nexport type AudioSelectionSet = SelectionSetOf<AudioTrack>;\n\n/**\n * Text selection set - contains only text switching sets.\n */\nexport type TextSelectionSet = SelectionSetOf<TextTrack>;\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// 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<TimeSpan> & {\n selectionSets: SelectionSet[];\n };\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: Presentation\n): presentation is Presentation & { duration: number } {\n return presentation.duration !== undefined;\n}\n"],"mappings":";AAsUA,SAAgB,gBAAgB,OAAuE;AACrG,QAAO,cAAc;;;;;;AAOvB,SAAgB,wBACd,cACqD;AACrD,QAAO,aAAa,aAAa,KAAA"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../../src/core/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<T extends Track = Track> = Omit<T, 'segments' | 'initialization' | keyof TimeSpan> & {\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<VideoTrack>;\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<AudioTrack>;\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\n/**\n * Resolved video track with segments.\n */\nexport type VideoTrack = Track &\n Required<Pick<Track, 'initialization' | 'codecs'>> & {\n type: 'video';\n\n // Optional metadata from multivariant (per HLS spec)\n width?: number;\n height?: number;\n frameRate?: FrameRate;\n audioGroupId?: string;\n };\n\n/**\n * Resolved audio track with segments.\n */\nexport type AudioTrack = Track &\n Required<Pick<Track, 'initialization' | 'codecs'>> & {\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 * 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<TextTrack>;\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<T extends Track = Track> = Ham & {\n type: T['type'];\n tracks: (PartiallyResolved<T> | T)[];\n};\n\n/**\n * Video switching set - contains only video tracks (partially resolved or fully resolved).\n */\nexport type VideoSwitchingSet = SwitchingSetOf<VideoTrack>;\n\n/**\n * Audio switching set - contains only audio tracks (partially resolved or fully resolved).\n */\nexport type AudioSwitchingSet = SwitchingSetOf<AudioTrack>;\n\n/**\n * Text switching set - contains only text tracks (partially resolved or fully resolved).\n */\nexport type TextSwitchingSet = SwitchingSetOf<TextTrack>;\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<T extends Track = Track> = Ham & {\n type: T['type'];\n switchingSets: SwitchingSetOf<T>[];\n};\n\n/**\n * Video selection set - contains only video switching sets.\n */\nexport type VideoSelectionSet = SelectionSetOf<VideoTrack>;\n\n/**\n * Audio selection set - contains only audio switching sets.\n */\nexport type AudioSelectionSet = SelectionSetOf<AudioTrack>;\n\n/**\n * Text selection set - contains only text switching sets.\n */\nexport type TextSelectionSet = SelectionSetOf<TextTrack>;\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// 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<TimeSpan> & {\n selectionSets: SelectionSet[];\n };\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: Presentation\n): presentation is Presentation & { duration: number } {\n return presentation.duration !== undefined;\n}\n"],"mappings":";AAgUA,SAAgB,gBAAgB,OAAuE;AACrG,QAAO,cAAc;;;;;;AAOvB,SAAgB,wBACd,cACqD;AACrD,QAAO,aAAa,aAAa,KAAA"}
@@ -53,8 +53,8 @@ function shouldEndStream(state, owners) {
53
53
  const hasAudioTrack = !!state.selectedAudioTrackId;
54
54
  if (hasVideoTrack && !owners.videoBuffer) return false;
55
55
  if (hasAudioTrack && !owners.audioBuffer) return false;
56
- if (owners.videoBufferActor?.snapshot.get().status === "updating") return false;
57
- if (owners.audioBufferActor?.snapshot.get().status === "updating") return false;
56
+ if (owners.videoBufferActor?.snapshot.get().value === "updating") return false;
57
+ if (owners.audioBufferActor?.snapshot.get().value === "updating") return false;
58
58
  if (!hasLastSegmentLoaded(state, owners)) return false;
59
59
  if (mediaElement) {
60
60
  const videoTrack = hasVideoTrack ? getSelectedTrack(state, "video") : void 0;
@@ -69,17 +69,17 @@ function shouldEndStream(state, owners) {
69
69
  }
70
70
  /**
71
71
  * Wait for all currently-updating SourceBufferActors to finish.
72
- * Uses actor status rather than raw SourceBuffer.updating so the wait is
72
+ * Uses actor state rather than raw SourceBuffer.updating so the wait is
73
73
  * aligned with the same abstraction that owns all buffer operations.
74
74
  */
75
75
  function waitForSourceBuffersReady(owners) {
76
- const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter((actor) => actor !== void 0 && actor.snapshot.get().status === "updating");
76
+ const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter((actor) => actor !== void 0 && actor.snapshot.get().value === "updating");
77
77
  if (updatingActors.length === 0) return Promise.resolve();
78
78
  return Promise.all(updatingActors.map((actor) => new Promise((resolve) => {
79
79
  let cleanup;
80
80
  let resolved = false;
81
81
  cleanup = effect(() => {
82
- if (actor.snapshot.get().status !== "updating") {
82
+ if (actor.snapshot.get().value !== "updating") {
83
83
  if (!resolved) {
84
84
  resolved = true;
85
85
  resolve();
@@ -1 +1 @@
1
- {"version":3,"file":"end-of-stream.js","names":[],"sources":["../../../../src/dom/features/end-of-stream.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport type { Presentation } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';\nimport type { SourceBufferActor } from '../media/source-buffer-actor';\n\nexport interface EndOfStreamState extends TrackSelectionState {\n presentation?: Presentation;\n}\n\nexport interface EndOfStreamOwners {\n mediaSource?: MediaSource;\n /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */\n mediaSourceReadyState?: ReadonlySignal<MediaSource['readyState']>;\n mediaElement?: HTMLMediaElement | undefined;\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\n// ## When to call endOfStream()\n//\n// Per the MSE spec, endOfStream() should be called once the last media\n// segments — temporally speaking — have been completely appended to all\n// active SourceBuffers. Specifically it signals two things:\n// 1. The temporally latest segments for both audio and video have been\n// appended (i.e. the buffer covers the end of the stream content).\n// 2. The MediaSource will transition from 'open' to 'ended'. Appending\n// additional (earlier) segments after this — e.g. for a seek-back or\n// back-buffer refill — will re-open the MediaSource, at which point\n// endOfStream() must be called again once loading reaches the end.\n//\n// The browser uses this signal to finalise MediaSource.duration and allow\n// the media element to fire the `ended` event. Without it, playback stalls\n// at the end of the buffered range waiting for data that will never arrive.\n//\n// The \"any track\" qualifier is intentional: per the HLS spec, all renditions\n// in a switching set are time-aligned, so the last segment of any rendition\n// covers the same end-of-stream content. We don't need to be tied to the\n// currently selected track.\n//\n// The right long-term condition is therefore:\n// - The last segment of the video content (from any resolved video track)\n// has been completely appended to the video SourceBuffer, AND\n// - The last segment of the audio content (from any resolved audio track)\n// has been completely appended to the audio SourceBuffer (when active), AND\n// - currentTime is within the time range of that last segment.\n//\n// The currentTime gate prevents unnecessary re-invocations when back-buffer\n// refills or other mid-stream appends briefly re-open the MediaSource while\n// the user is far from the end.\n//\n/**\n * Check if the last segment of a track has been appended to a SourceBuffer.\n *\n * Checks by segment ID rather than a pipeline flag, so it is robust across\n * quality switches (different tracks have different segment IDs) and\n * back-buffer flushes (flushed segment IDs are removed from the model).\n */\nfunction isLastSegmentAppended(segments: readonly { id: string }[], actor: SourceBufferActor | undefined): boolean {\n if (segments.length === 0) return true;\n const lastSeg = segments[segments.length - 1];\n if (!lastSeg) return false;\n // A partial segment is still streaming — the last segment is not ready until\n // its entry is present and not marked partial.\n return actor?.snapshot.get().context.segments.some((s) => s.id === lastSeg.id && !s.partial) ?? false;\n}\n\n/**\n * Check if the last segment has been appended for each selected track.\n *\n * Handles video-only, audio-only, and video+audio scenarios.\n * A track with no segments (e.g. unresolved) is considered not ready.\n */\nexport function hasLastSegmentLoaded(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n const videoTrack = state.selectedVideoTrackId ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = state.selectedAudioTrackId ? getSelectedTrack(state, 'audio') : undefined;\n\n // An unresolved track means we don't yet know its segments — cannot be done.\n // Fast-paths the quality-switch window: when selectedVideoTrackId has changed\n // to a new (unresolved) track, we cannot yet determine if its last segment\n // is loaded.\n if (videoTrack && !isResolvedTrack(videoTrack)) return false;\n if (audioTrack && !isResolvedTrack(audioTrack)) return false;\n\n if (videoTrack && isResolvedTrack(videoTrack)) {\n if (!isLastSegmentAppended(videoTrack.segments, owners.videoBufferActor)) return false;\n }\n\n if (audioTrack && isResolvedTrack(audioTrack)) {\n if (!isLastSegmentAppended(audioTrack.segments, owners.audioBufferActor)) return false;\n }\n\n return true;\n}\n\n/**\n * Check if we can call endOfStream.\n */\nexport function canEndStream(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n return !!(owners.mediaSource && state.presentation);\n}\n\n/**\n * Check if we should call endOfStream.\n */\nexport function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n if (!canEndStream(state, owners)) return false;\n\n const { mediaElement } = owners;\n\n // MediaSource must be open — use reactive readyState signal when available\n // so the computed re-evaluates when readyState changes (e.g. 'ended' → 'open' on seek-back).\n const readyState = owners.mediaSourceReadyState?.get() ?? owners.mediaSource?.readyState;\n if (readyState !== 'open') return false;\n\n // CRITICAL: MediaElement must have metadata before calling endOfStream\n // Calling endOfStream before HAVE_METADATA causes DEMUXER_ERROR\n // https://github.com/chromium/chromium/blob/main/media/filters/chunk_demuxer.cc\n if (mediaElement && mediaElement.readyState < HTMLMediaElement.HAVE_METADATA) {\n return false;\n }\n\n // SourceBuffers must exist for selected tracks before we can end the stream\n // (otherwise we'd close the MediaSource before SourceBuffers are created)\n const hasVideoTrack = !!state.selectedVideoTrackId;\n const hasAudioTrack = !!state.selectedAudioTrackId;\n\n if (hasVideoTrack && !owners.videoBuffer) return false;\n if (hasAudioTrack && !owners.audioBuffer) return false;\n\n // SourceBufferActors must be idle — setting duration while a SourceBuffer is\n // updating throws InvalidStateError. The actor subscriber in endOfStream() will\n // re-evaluate when each actor transitions back to idle.\n if (owners.videoBufferActor?.snapshot.get().status === 'updating') return false;\n if (owners.audioBufferActor?.snapshot.get().status === 'updating') return false;\n\n // Last segment must be appended for each selected track\n if (!hasLastSegmentLoaded(state, owners)) return false;\n\n // currentTime must have reached the last segment. Guards against re-ending\n // the stream when a back-buffer remove() re-opens the MediaSource while the\n // user is far from the end (remove() re-opens 'ended' → 'open' per MSE spec,\n // same as appendBuffer()).\n if (mediaElement) {\n const videoTrack = hasVideoTrack ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = hasAudioTrack ? getSelectedTrack(state, 'audio') : undefined;\n const refTrack =\n videoTrack && isResolvedTrack(videoTrack)\n ? videoTrack\n : audioTrack && isResolvedTrack(audioTrack)\n ? audioTrack\n : undefined;\n if (refTrack && refTrack.segments.length > 0) {\n const lastSeg = refTrack.segments[refTrack.segments.length - 1]!;\n if (mediaElement.currentTime < lastSeg.startTime) return false;\n }\n }\n\n return true;\n}\n\n/**\n * Wait for all currently-updating SourceBufferActors to finish.\n * Uses actor status rather than raw SourceBuffer.updating so the wait is\n * aligned with the same abstraction that owns all buffer operations.\n */\nfunction waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise<void> {\n const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter(\n (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.get().status === 'updating'\n );\n\n if (updatingActors.length === 0) return Promise.resolve();\n\n return Promise.all(\n updatingActors.map(\n (actor) =>\n new Promise<void>((resolve) => {\n // effect() runs its body synchronously on creation, then re-runs on the\n // next microtask after any dependency changes. If the actor is already\n // idle by the time effect() is called, resolve fires immediately and\n // cleanup is scheduled via queueMicrotask to avoid unwatching during\n // the watcher notification cycle.\n let cleanup: (() => void) | undefined;\n let resolved = false;\n cleanup = effect(() => {\n if (actor.snapshot.get().status !== 'updating') {\n if (!resolved) {\n resolved = true;\n resolve();\n }\n queueMicrotask(() => cleanup?.());\n }\n });\n })\n )\n ).then(() => undefined);\n}\n\n/**\n * Get the highest buffered end time across all active SourceBuffers.\n * Used to set the final duration from actual container timestamps rather\n * than playlist metadata, which handles both shorter and longer cases.\n */\nfunction getMaxBufferedEnd(owners: EndOfStreamOwners): number {\n let max = 0;\n for (const buf of [owners.videoBuffer, owners.audioBuffer]) {\n if (buf && buf.buffered.length > 0) {\n const end = buf.buffered.end(buf.buffered.length - 1);\n if (end > max) max = end;\n }\n }\n return max;\n}\n\n/**\n * End of stream task (module-level, pure).\n * Sets the final duration from actual buffered end time, then calls endOfStream().\n */\nconst endOfStreamTask = async (\n { currentOwners }: { currentOwners: EndOfStreamOwners },\n _context: object\n): Promise<void> => {\n const { mediaSource } = currentOwners;\n\n // Double-check MediaSource isn't already ended (in case of race)\n if (mediaSource!.readyState === 'ended') {\n return;\n }\n\n // Wait for any in-progress SourceBuffer operations to finish before calling\n // endOfStream() — the MSE spec forbids it while any buffer has updating === true.\n await waitForSourceBuffersReady(currentOwners);\n\n // Re-check after the async wait\n if (mediaSource!.readyState !== 'open') return;\n\n // Set the final duration from actual buffered container timestamps.\n // This is more accurate than the playlist-derived duration and correctly\n // handles both shorter (common with CMAF) and longer actual media durations.\n // Per MSE spec, endOfStream() will only *increase* duration if needed, so\n // setting it here first ensures the value from the buffer wins in all cases.\n const bufferedEnd = getMaxBufferedEnd(currentOwners);\n if (bufferedEnd > 0) {\n mediaSource!.duration = bufferedEnd;\n }\n\n mediaSource!.endOfStream();\n\n // Wait a frame to allow async state updates to flush\n await new Promise((resolve) => requestAnimationFrame(resolve));\n};\n\n/**\n * Call endOfStream when the last segment has been appended.\n * This signals to the browser that the stream is complete.\n *\n * Per the MSE spec, appendBuffer() remains valid after endOfStream() —\n * seeks that require re-appending earlier segments will still work.\n * What becomes blocked is calling endOfStream() again, addSourceBuffer(),\n * and MediaSource.duration updates.\n */\nexport function endOfStream<S extends EndOfStreamState, O extends EndOfStreamOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n // Derived condition. Transitively tracks through owners into each actor's\n // snapshot signal — when owners changes and points to a new actor, this computed\n // re-tracks to the new actor's signal on next evaluation.\n // shouldEndStream calls actor.snapshot.get() inside the computed body, so those\n // reads are automatically tracked by the Signal.Computed dependency graph.\n const shouldEnd = computed(() => shouldEndStream(state.get(), owners.get()));\n\n let hasEnded = false;\n\n const cleanupEffect = effect(() => {\n if (!shouldEnd.get()) return;\n const currentOwners = owners.get();\n if (hasEnded) {\n // Per the MSE spec, calling appendBuffer() on a SourceBuffer when\n // readyState is 'ended' automatically transitions it back to 'open'.\n // This happens on seek-back after end-of-stream — allow endOfStream()\n // to be called again once the last segment is reloaded.\n const readyState = currentOwners.mediaSourceReadyState?.get() ?? currentOwners.mediaSource?.readyState;\n if (readyState !== 'open') return;\n hasEnded = false;\n }\n\n // Set flag before awaiting to close the re-entry window between\n // endOfStream() being called and the async task completing.\n hasEnded = true;\n endOfStreamTask({ currentOwners }, {}).catch((error) => console.error('Failed to call endOfStream:', error));\n });\n\n return cleanupEffect;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"end-of-stream.js","names":[],"sources":["../../../../src/dom/features/end-of-stream.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport type { Presentation } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';\nimport type { SourceBufferActor } from '../media/source-buffer-actor';\n\nexport interface EndOfStreamState extends TrackSelectionState {\n presentation?: Presentation;\n}\n\nexport interface EndOfStreamOwners {\n mediaSource?: MediaSource;\n /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */\n mediaSourceReadyState?: ReadonlySignal<MediaSource['readyState']>;\n mediaElement?: HTMLMediaElement | undefined;\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\n// ## When to call endOfStream()\n//\n// Per the MSE spec, endOfStream() should be called once the last media\n// segments — temporally speaking — have been completely appended to all\n// active SourceBuffers. Specifically it signals two things:\n// 1. The temporally latest segments for both audio and video have been\n// appended (i.e. the buffer covers the end of the stream content).\n// 2. The MediaSource will transition from 'open' to 'ended'. Appending\n// additional (earlier) segments after this — e.g. for a seek-back or\n// back-buffer refill — will re-open the MediaSource, at which point\n// endOfStream() must be called again once loading reaches the end.\n//\n// The browser uses this signal to finalise MediaSource.duration and allow\n// the media element to fire the `ended` event. Without it, playback stalls\n// at the end of the buffered range waiting for data that will never arrive.\n//\n// The \"any track\" qualifier is intentional: per the HLS spec, all renditions\n// in a switching set are time-aligned, so the last segment of any rendition\n// covers the same end-of-stream content. We don't need to be tied to the\n// currently selected track.\n//\n// The right long-term condition is therefore:\n// - The last segment of the video content (from any resolved video track)\n// has been completely appended to the video SourceBuffer, AND\n// - The last segment of the audio content (from any resolved audio track)\n// has been completely appended to the audio SourceBuffer (when active), AND\n// - currentTime is within the time range of that last segment.\n//\n// The currentTime gate prevents unnecessary re-invocations when back-buffer\n// refills or other mid-stream appends briefly re-open the MediaSource while\n// the user is far from the end.\n//\n/**\n * Check if the last segment of a track has been appended to a SourceBuffer.\n *\n * Checks by segment ID rather than a pipeline flag, so it is robust across\n * quality switches (different tracks have different segment IDs) and\n * back-buffer flushes (flushed segment IDs are removed from the model).\n */\nfunction isLastSegmentAppended(segments: readonly { id: string }[], actor: SourceBufferActor | undefined): boolean {\n if (segments.length === 0) return true;\n const lastSeg = segments[segments.length - 1];\n if (!lastSeg) return false;\n // A partial segment is still streaming — the last segment is not ready until\n // its entry is present and not marked partial.\n return actor?.snapshot.get().context.segments.some((s) => s.id === lastSeg.id && !s.partial) ?? false;\n}\n\n/**\n * Check if the last segment has been appended for each selected track.\n *\n * Handles video-only, audio-only, and video+audio scenarios.\n * A track with no segments (e.g. unresolved) is considered not ready.\n */\nexport function hasLastSegmentLoaded(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n const videoTrack = state.selectedVideoTrackId ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = state.selectedAudioTrackId ? getSelectedTrack(state, 'audio') : undefined;\n\n // An unresolved track means we don't yet know its segments — cannot be done.\n // Fast-paths the quality-switch window: when selectedVideoTrackId has changed\n // to a new (unresolved) track, we cannot yet determine if its last segment\n // is loaded.\n if (videoTrack && !isResolvedTrack(videoTrack)) return false;\n if (audioTrack && !isResolvedTrack(audioTrack)) return false;\n\n if (videoTrack && isResolvedTrack(videoTrack)) {\n if (!isLastSegmentAppended(videoTrack.segments, owners.videoBufferActor)) return false;\n }\n\n if (audioTrack && isResolvedTrack(audioTrack)) {\n if (!isLastSegmentAppended(audioTrack.segments, owners.audioBufferActor)) return false;\n }\n\n return true;\n}\n\n/**\n * Check if we can call endOfStream.\n */\nexport function canEndStream(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n return !!(owners.mediaSource && state.presentation);\n}\n\n/**\n * Check if we should call endOfStream.\n */\nexport function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwners): boolean {\n if (!canEndStream(state, owners)) return false;\n\n const { mediaElement } = owners;\n\n // MediaSource must be open — use reactive readyState signal when available\n // so the computed re-evaluates when readyState changes (e.g. 'ended' → 'open' on seek-back).\n const readyState = owners.mediaSourceReadyState?.get() ?? owners.mediaSource?.readyState;\n if (readyState !== 'open') return false;\n\n // CRITICAL: MediaElement must have metadata before calling endOfStream\n // Calling endOfStream before HAVE_METADATA causes DEMUXER_ERROR\n // https://github.com/chromium/chromium/blob/main/media/filters/chunk_demuxer.cc\n if (mediaElement && mediaElement.readyState < HTMLMediaElement.HAVE_METADATA) {\n return false;\n }\n\n // SourceBuffers must exist for selected tracks before we can end the stream\n // (otherwise we'd close the MediaSource before SourceBuffers are created)\n const hasVideoTrack = !!state.selectedVideoTrackId;\n const hasAudioTrack = !!state.selectedAudioTrackId;\n\n if (hasVideoTrack && !owners.videoBuffer) return false;\n if (hasAudioTrack && !owners.audioBuffer) return false;\n\n // SourceBufferActors must be idle — setting duration while a SourceBuffer is\n // updating throws InvalidStateError. The actor subscriber in endOfStream() will\n // re-evaluate when each actor transitions back to idle.\n if (owners.videoBufferActor?.snapshot.get().value === 'updating') return false;\n if (owners.audioBufferActor?.snapshot.get().value === 'updating') return false;\n\n // Last segment must be appended for each selected track\n if (!hasLastSegmentLoaded(state, owners)) return false;\n\n // currentTime must have reached the last segment. Guards against re-ending\n // the stream when a back-buffer remove() re-opens the MediaSource while the\n // user is far from the end (remove() re-opens 'ended' → 'open' per MSE spec,\n // same as appendBuffer()).\n if (mediaElement) {\n const videoTrack = hasVideoTrack ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = hasAudioTrack ? getSelectedTrack(state, 'audio') : undefined;\n const refTrack =\n videoTrack && isResolvedTrack(videoTrack)\n ? videoTrack\n : audioTrack && isResolvedTrack(audioTrack)\n ? audioTrack\n : undefined;\n if (refTrack && refTrack.segments.length > 0) {\n const lastSeg = refTrack.segments[refTrack.segments.length - 1]!;\n if (mediaElement.currentTime < lastSeg.startTime) return false;\n }\n }\n\n return true;\n}\n\n/**\n * Wait for all currently-updating SourceBufferActors to finish.\n * Uses actor state rather than raw SourceBuffer.updating so the wait is\n * aligned with the same abstraction that owns all buffer operations.\n */\nfunction waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise<void> {\n const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter(\n (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.get().value === 'updating'\n );\n\n if (updatingActors.length === 0) return Promise.resolve();\n\n return Promise.all(\n updatingActors.map(\n (actor) =>\n new Promise<void>((resolve) => {\n // effect() runs its body synchronously on creation, then re-runs on the\n // next microtask after any dependency changes. If the actor is already\n // idle by the time effect() is called, resolve fires immediately and\n // cleanup is scheduled via queueMicrotask to avoid unwatching during\n // the watcher notification cycle.\n let cleanup: (() => void) | undefined;\n let resolved = false;\n cleanup = effect(() => {\n if (actor.snapshot.get().value !== 'updating') {\n if (!resolved) {\n resolved = true;\n resolve();\n }\n queueMicrotask(() => cleanup?.());\n }\n });\n })\n )\n ).then(() => undefined);\n}\n\n/**\n * Get the highest buffered end time across all active SourceBuffers.\n * Used to set the final duration from actual container timestamps rather\n * than playlist metadata, which handles both shorter and longer cases.\n */\nfunction getMaxBufferedEnd(owners: EndOfStreamOwners): number {\n let max = 0;\n for (const buf of [owners.videoBuffer, owners.audioBuffer]) {\n if (buf && buf.buffered.length > 0) {\n const end = buf.buffered.end(buf.buffered.length - 1);\n if (end > max) max = end;\n }\n }\n return max;\n}\n\n/**\n * End of stream task (module-level, pure).\n * Sets the final duration from actual buffered end time, then calls endOfStream().\n */\nconst endOfStreamTask = async (\n { currentOwners }: { currentOwners: EndOfStreamOwners },\n _context: object\n): Promise<void> => {\n const { mediaSource } = currentOwners;\n\n // Double-check MediaSource isn't already ended (in case of race)\n if (mediaSource!.readyState === 'ended') {\n return;\n }\n\n // Wait for any in-progress SourceBuffer operations to finish before calling\n // endOfStream() — the MSE spec forbids it while any buffer has updating === true.\n await waitForSourceBuffersReady(currentOwners);\n\n // Re-check after the async wait\n if (mediaSource!.readyState !== 'open') return;\n\n // Set the final duration from actual buffered container timestamps.\n // This is more accurate than the playlist-derived duration and correctly\n // handles both shorter (common with CMAF) and longer actual media durations.\n // Per MSE spec, endOfStream() will only *increase* duration if needed, so\n // setting it here first ensures the value from the buffer wins in all cases.\n const bufferedEnd = getMaxBufferedEnd(currentOwners);\n if (bufferedEnd > 0) {\n mediaSource!.duration = bufferedEnd;\n }\n\n mediaSource!.endOfStream();\n\n // Wait a frame to allow async state updates to flush\n await new Promise((resolve) => requestAnimationFrame(resolve));\n};\n\n/**\n * Call endOfStream when the last segment has been appended.\n * This signals to the browser that the stream is complete.\n *\n * Per the MSE spec, appendBuffer() remains valid after endOfStream() —\n * seeks that require re-appending earlier segments will still work.\n * What becomes blocked is calling endOfStream() again, addSourceBuffer(),\n * and MediaSource.duration updates.\n */\nexport function endOfStream<S extends EndOfStreamState, O extends EndOfStreamOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n // Derived condition. Transitively tracks through owners into each actor's\n // snapshot signal — when owners changes and points to a new actor, this computed\n // re-tracks to the new actor's signal on next evaluation.\n // shouldEndStream calls actor.snapshot.get() inside the computed body, so those\n // reads are automatically tracked by the Signal.Computed dependency graph.\n const shouldEnd = computed(() => shouldEndStream(state.get(), owners.get()));\n\n let hasEnded = false;\n\n const cleanupEffect = effect(() => {\n if (!shouldEnd.get()) return;\n const currentOwners = owners.get();\n if (hasEnded) {\n // Per the MSE spec, calling appendBuffer() on a SourceBuffer when\n // readyState is 'ended' automatically transitions it back to 'open'.\n // This happens on seek-back after end-of-stream — allow endOfStream()\n // to be called again once the last segment is reloaded.\n const readyState = currentOwners.mediaSourceReadyState?.get() ?? currentOwners.mediaSource?.readyState;\n if (readyState !== 'open') return;\n hasEnded = false;\n }\n\n // Set flag before awaiting to close the re-entry window between\n // endOfStream() being called and the async task completing.\n hasEnded = true;\n endOfStreamTask({ currentOwners }, {}).catch((error) => console.error('Failed to call endOfStream:', error));\n });\n\n return cleanupEffect;\n}\n"],"mappings":";;;;;;;;;;;;AA6DA,SAAS,sBAAsB,UAAqC,OAA+C;AACjH,KAAI,SAAS,WAAW,EAAG,QAAO;CAClC,MAAM,UAAU,SAAS,SAAS,SAAS;AAC3C,KAAI,CAAC,QAAS,QAAO;AAGrB,QAAO,OAAO,SAAS,KAAK,CAAC,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,QAAQ,MAAM,CAAC,EAAE,QAAQ,IAAI;;;;;;;;AASlG,SAAgB,qBAAqB,OAAyB,QAAoC;CAChG,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;CACnF,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;AAMnF,KAAI,cAAc,CAAC,gBAAgB,WAAW,CAAE,QAAO;AACvD,KAAI,cAAc,CAAC,gBAAgB,WAAW,CAAE,QAAO;AAEvD,KAAI,cAAc,gBAAgB,WAAW;MACvC,CAAC,sBAAsB,WAAW,UAAU,OAAO,iBAAiB,CAAE,QAAO;;AAGnF,KAAI,cAAc,gBAAgB,WAAW;MACvC,CAAC,sBAAsB,WAAW,UAAU,OAAO,iBAAiB,CAAE,QAAO;;AAGnF,QAAO;;;;;AAMT,SAAgB,aAAa,OAAyB,QAAoC;AACxF,QAAO,CAAC,EAAE,OAAO,eAAe,MAAM;;;;;AAMxC,SAAgB,gBAAgB,OAAyB,QAAoC;AAC3F,KAAI,CAAC,aAAa,OAAO,OAAO,CAAE,QAAO;CAEzC,MAAM,EAAE,iBAAiB;AAKzB,MADmB,OAAO,uBAAuB,KAAK,IAAI,OAAO,aAAa,gBAC3D,OAAQ,QAAO;AAKlC,KAAI,gBAAgB,aAAa,aAAa,iBAAiB,cAC7D,QAAO;CAKT,MAAM,gBAAgB,CAAC,CAAC,MAAM;CAC9B,MAAM,gBAAgB,CAAC,CAAC,MAAM;AAE9B,KAAI,iBAAiB,CAAC,OAAO,YAAa,QAAO;AACjD,KAAI,iBAAiB,CAAC,OAAO,YAAa,QAAO;AAKjD,KAAI,OAAO,kBAAkB,SAAS,KAAK,CAAC,UAAU,WAAY,QAAO;AACzE,KAAI,OAAO,kBAAkB,SAAS,KAAK,CAAC,UAAU,WAAY,QAAO;AAGzE,KAAI,CAAC,qBAAqB,OAAO,OAAO,CAAE,QAAO;AAMjD,KAAI,cAAc;EAChB,MAAM,aAAa,gBAAgB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;EACtE,MAAM,aAAa,gBAAgB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;EACtE,MAAM,WACJ,cAAc,gBAAgB,WAAW,GACrC,aACA,cAAc,gBAAgB,WAAW,GACvC,aACA,KAAA;AACR,MAAI,YAAY,SAAS,SAAS,SAAS,GAAG;GAC5C,MAAM,UAAU,SAAS,SAAS,SAAS,SAAS,SAAS;AAC7D,OAAI,aAAa,cAAc,QAAQ,UAAW,QAAO;;;AAI7D,QAAO;;;;;;;AAQT,SAAS,0BAA0B,QAA0C;CAC3E,MAAM,iBAAiB,CAAC,OAAO,kBAAkB,OAAO,iBAAiB,CAAC,QACvE,UAAsC,UAAU,KAAA,KAAa,MAAM,SAAS,KAAK,CAAC,UAAU,WAC9F;AAED,KAAI,eAAe,WAAW,EAAG,QAAO,QAAQ,SAAS;AAEzD,QAAO,QAAQ,IACb,eAAe,KACZ,UACC,IAAI,SAAe,YAAY;EAM7B,IAAI;EACJ,IAAI,WAAW;AACf,YAAU,aAAa;AACrB,OAAI,MAAM,SAAS,KAAK,CAAC,UAAU,YAAY;AAC7C,QAAI,CAAC,UAAU;AACb,gBAAW;AACX,cAAS;;AAEX,yBAAqB,WAAW,CAAC;;IAEnC;GACF,CACL,CACF,CAAC,WAAW,KAAA,EAAU;;;;;;;AAQzB,SAAS,kBAAkB,QAAmC;CAC5D,IAAI,MAAM;AACV,MAAK,MAAM,OAAO,CAAC,OAAO,aAAa,OAAO,YAAY,CACxD,KAAI,OAAO,IAAI,SAAS,SAAS,GAAG;EAClC,MAAM,MAAM,IAAI,SAAS,IAAI,IAAI,SAAS,SAAS,EAAE;AACrD,MAAI,MAAM,IAAK,OAAM;;AAGzB,QAAO;;;;;;AAOT,MAAM,kBAAkB,OACtB,EAAE,iBACF,aACkB;CAClB,MAAM,EAAE,gBAAgB;AAGxB,KAAI,YAAa,eAAe,QAC9B;AAKF,OAAM,0BAA0B,cAAc;AAG9C,KAAI,YAAa,eAAe,OAAQ;CAOxC,MAAM,cAAc,kBAAkB,cAAc;AACpD,KAAI,cAAc,EAChB,aAAa,WAAW;AAG1B,aAAa,aAAa;AAG1B,OAAM,IAAI,SAAS,YAAY,sBAAsB,QAAQ,CAAC;;;;;;;;;;;AAYhE,SAAgB,YAAqE,EACnF,OACA,UAIa;CAMb,MAAM,YAAY,eAAe,gBAAgB,MAAM,KAAK,EAAE,OAAO,KAAK,CAAC,CAAC;CAE5E,IAAI,WAAW;AAqBf,QAnBsB,aAAa;AACjC,MAAI,CAAC,UAAU,KAAK,CAAE;EACtB,MAAM,gBAAgB,OAAO,KAAK;AAClC,MAAI,UAAU;AAMZ,QADmB,cAAc,uBAAuB,KAAK,IAAI,cAAc,aAAa,gBACzE,OAAQ;AAC3B,cAAW;;AAKb,aAAW;AACX,kBAAgB,EAAE,eAAe,EAAE,EAAE,CAAC,CAAC,OAAO,UAAU,QAAQ,MAAM,+BAA+B,MAAM,CAAC;GAC5G"}
@@ -1,159 +1,102 @@
1
- import { getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
2
- import { effect } from "../../core/signals/effect.js";
3
- import { computed } from "../../core/signals/primitives.js";
1
+ import { computed, untrack, update } from "../../core/signals/primitives.js";
4
2
  import { isResolvedTrack } from "../../core/types/index.js";
5
- import { parseVttSegment } from "../text/parse-vtt-segment.js";
3
+ import { createMachineReactor } from "../../core/create-machine-reactor.js";
4
+ import { createTextTrackSegmentLoaderActor } from "./text-track-segment-loader-actor.js";
5
+ import { createTextTracksActor } from "./text-tracks-actor.js";
6
6
  //#region src/dom/features/load-text-track-cues.ts
7
- const CueKeys = [
8
- "startTime",
9
- "endTime",
10
- "text"
11
- ];
12
- function isDuplicateCue(cue, existingCues) {
13
- return Array.prototype.some.call(existingCues ?? [], (existingCue) => {
14
- return CueKeys.every((k) => existingCue[k] === cue[k]);
15
- });
7
+ function getTextTracks(presentation) {
8
+ return presentation?.selectionSets?.find((s) => s.type === "text")?.switchingSets[0]?.tracks;
16
9
  }
17
- const loadVttSegmentTask = async ({ segment }, { textTrack }) => {
18
- (await parseVttSegment(segment.url)).forEach((cue) => {
19
- if (isDuplicateCue(cue, textTrack.cues)) return;
20
- textTrack.addCue(cue);
21
- });
22
- };
23
- /**
24
- * Load text track cues task (composite - orchestrates VTT segment subtasks).
25
- */
26
- const loadTextTrackCuesTask = async ({ currentState }, context) => {
27
- const track = findSelectedTextTrack(currentState);
28
- if (!track || !isResolvedTrack(track)) return;
29
- const { segments } = track;
30
- if (segments.length === 0) return;
31
- const trackId = track.id;
32
- const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id));
33
- const segmentsToLoad = getSegmentsToLoad(segments, segments.filter((s) => loadedIds.has(s.id)), currentState.currentTime ?? 0).filter((s) => !loadedIds.has(s.id));
34
- if (segmentsToLoad.length === 0) return;
35
- for (const segment of segmentsToLoad) {
36
- if (context.signal.aborted) break;
37
- try {
38
- await loadVttSegmentTask({ segment }, { textTrack: context.textTrack });
39
- const latestState = context.state.get();
40
- const latest = latestState.textBufferState ?? {};
41
- const trackState = latest[trackId] ?? { segments: [] };
42
- context.state.set({
43
- ...latestState,
44
- textBufferState: {
45
- ...latest,
46
- [trackId]: { segments: [...trackState.segments, { id: segment.id }] }
47
- }
48
- });
49
- } catch (error) {
50
- if (error instanceof Error && error.name === "AbortError") break;
51
- console.error("Failed to load VTT segment:", error);
52
- }
53
- }
54
- if (context.textTrack.mode === "showing" && context.textTrack.cues) Array.from(context.textTrack.cues).forEach((cue) => {
55
- context.textTrack.addCue(cue);
56
- });
57
- await new Promise((resolve) => requestAnimationFrame(resolve));
58
- };
59
- /**
60
- * Find the selected text track in the presentation.
61
- */
62
- function findSelectedTextTrack(state) {
63
- if (!state.presentation || !state.selectedTextTrackId) return;
64
- const textSet = state.presentation.selectionSets.find((set) => set.type === "text");
65
- if (!textSet?.switchingSets?.[0]?.tracks) return;
66
- return textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId);
10
+ function findSelectedTrack(state) {
11
+ const track = getTextTracks(state.presentation)?.find((t) => t.id === state.selectedTextTrackId);
12
+ return track && isResolvedTrack(track) ? track : void 0;
67
13
  }
68
14
  /**
69
- * Get the browser's TextTrack object for the selected text track.
70
- *
71
- * Retrieves the live TextTrack interface from the track element in owners,
72
- * which is used for adding cues, checking mode, and managing track state.
15
+ * Derives the correct state from current state and owners.
73
16
  *
74
- * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track),
75
- * not the presentation Track metadata type.
76
- *
77
- * @param state - Current playback state (track selection)
78
- * @param owners - DOM owners containing track elements map
79
- * @returns DOM TextTrack interface or undefined if not found
17
+ * States are mutually exclusive and exhaustive:
18
+ * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks
19
+ * - `'setting-up'`: preconditions met; actors not yet in owners
20
+ * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM
21
+ * - `'monitoring-for-loads'`: selected track resolved, in DOM ready to dispatch load messages
80
22
  */
81
- function getSelectedTextTrackFromOwners(state, owners) {
82
- const trackId = state.selectedTextTrackId;
83
- if (!trackId || !owners.textTracks) return;
84
- return owners.textTracks.get(trackId)?.track;
23
+ function deriveState(state, owners) {
24
+ if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) return "preconditions-unmet";
25
+ if (!owners.textTracksActor || !owners.segmentLoaderActor) return "setting-up";
26
+ const track = findSelectedTrack(state);
27
+ if (!track || track.segments.length === 0) return "pending";
28
+ if (!Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId)) return "pending";
29
+ return "monitoring-for-loads";
85
30
  }
86
- /**
87
- * Check if we can load text track cues.
88
- *
89
- * Requires:
90
- * - Selected text track ID exists
91
- * - Track elements map exists
92
- * - Track element exists for selected track
93
- */
94
- function canLoadTextTrackCues(state, owners) {
95
- return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId);
31
+ function teardownActors(owners) {
32
+ const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get());
33
+ if (!textTracksActor && !segmentLoaderActor) return;
34
+ textTracksActor?.destroy();
35
+ segmentLoaderActor?.destroy();
36
+ update(owners, {
37
+ textTracksActor: void 0,
38
+ segmentLoaderActor: void 0
39
+ });
96
40
  }
97
41
  /**
98
- * Check if we should load text track cues.
42
+ * Text track cue loading orchestration.
99
43
  *
100
- * Only load if:
101
- * - Track is resolved (has segments)
102
- * - Track has at least one segment
103
- * - Track element exists
104
- */
105
- function shouldLoadTextTrackCues(state, owners) {
106
- if (!canLoadTextTrackCues(state, owners)) return false;
107
- const track = findSelectedTextTrack(state);
108
- if (!track || !isResolvedTrack(track) || track.segments.length === 0) return false;
109
- if (!getSelectedTextTrackFromOwners(state, owners)) return false;
110
- return true;
111
- }
112
- /**
113
- * Load text track cues orchestration.
44
+ * A single `always` monitor keeps the reactor in sync with conditions.
45
+ * Actor lifecycle is managed across two states:
114
46
  *
115
- * Triggers when:
116
- * - Text track is selected
117
- * - Track is resolved (has segments)
118
- * - Track element exists
47
+ * - **`'setting-up'`** — creates `TextTracksActor` and `TextTrackSegmentLoaderActor`
48
+ * and writes them to owners. Entry resets any stale actors first.
49
+ * - **`'preconditions-unmet'`** destroys any actors in owners and resets them to
50
+ * `undefined`. Handles all paths back from active states.
51
+ * - **`'monitoring-for-loads'`** — reactive dispatch: re-runs whenever `state`
52
+ * changes (selection, currentTime, presentation) and sends a `load` message to
53
+ * the segment loader.
119
54
  *
120
- * Fetches and parses VTT segments within the forward buffer window, then adds
121
- * cues to the track incrementally. Continues on segment errors to provide
122
- * partial subtitles.
55
+ * **Destroy note:** actors written to owners are NOT auto-destroyed when
56
+ * `reactor.destroy()` is called. Callers must read them from owners and destroy
57
+ * them explicitly alongside the reactor.
123
58
  *
124
59
  * @example
125
- * const cleanup = loadTextTrackCues({ state, owners });
60
+ * const reactor = loadTextTrackCues({ state, owners });
61
+ * // later:
62
+ * const { textTracksActor, segmentLoaderActor } = owners.get();
63
+ * textTracksActor?.destroy();
64
+ * segmentLoaderActor?.destroy();
65
+ * reactor.destroy();
126
66
  */
127
67
  function loadTextTrackCues({ state, owners }) {
128
- let currentTask = null;
129
- let abortController = null;
130
- let lastTrackId;
131
- const selectedTrackId = computed(() => state.get().selectedTextTrackId);
132
- const cleanupEffect = effect(() => {
133
- const s = state.get();
134
- const o = owners.get();
135
- if (selectedTrackId.get() !== lastTrackId) {
136
- lastTrackId = selectedTrackId.get();
137
- abortController?.abort();
138
- currentTask = null;
68
+ const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));
69
+ const currentTimeSignal = computed(() => state.get().currentTime ?? 0);
70
+ const selectedTrackSignal = computed(() => findSelectedTrack(state.get()));
71
+ return createMachineReactor({
72
+ initial: "preconditions-unmet",
73
+ monitor: () => derivedStateSignal.get(),
74
+ states: {
75
+ "preconditions-unmet": { entry: () => {
76
+ teardownActors(owners);
77
+ } },
78
+ "setting-up": { entry: () => {
79
+ teardownActors(owners);
80
+ const mediaElement = owners.get().mediaElement;
81
+ const textTracksActor = createTextTracksActor(mediaElement);
82
+ update(owners, {
83
+ textTracksActor,
84
+ segmentLoaderActor: createTextTrackSegmentLoaderActor(textTracksActor)
85
+ });
86
+ } },
87
+ pending: {},
88
+ "monitoring-for-loads": { effects: () => {
89
+ const currentTime = currentTimeSignal.get();
90
+ const track = selectedTrackSignal.get();
91
+ const { segmentLoaderActor } = untrack(() => owners.get());
92
+ segmentLoaderActor.send({
93
+ type: "load",
94
+ track,
95
+ currentTime
96
+ });
97
+ } }
139
98
  }
140
- if (currentTask) return;
141
- if (!shouldLoadTextTrackCues(s, o)) return;
142
- const textTrack = getSelectedTextTrackFromOwners(s, o);
143
- if (!textTrack) return;
144
- abortController = new AbortController();
145
- currentTask = loadTextTrackCuesTask({ currentState: s }, {
146
- signal: abortController.signal,
147
- textTrack,
148
- state
149
- }).finally(() => {
150
- currentTask = null;
151
- });
152
99
  });
153
- return () => {
154
- abortController?.abort();
155
- cleanupEffect();
156
- };
157
100
  }
158
101
  //#endregion
159
102
  export { loadTextTrackCues };
@@ -1 +1 @@
1
- {"version":3,"file":"load-text-track-cues.js","names":[],"sources":["../../../../src/dom/features/load-text-track-cues.ts"],"sourcesContent":["import { getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport { effect } from '../../core/signals/effect';\nimport { computed, type Signal } from '../../core/signals/primitives';\nimport type { Presentation, Segment, TextTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { parseVttSegment } from '../text/parse-vtt-segment';\n\nconst CueKeys = ['startTime', 'endTime', 'text'] as const;\nfunction isDuplicateCue(cue: VTTCue, existingCues: globalThis.TextTrack['cues']): boolean {\n return Array.prototype.some.call(existingCues ?? [], (existingCue) => {\n return CueKeys.every((k) => existingCue[k] === cue[k]);\n });\n}\n\nconst loadVttSegmentTask = async (\n { segment }: { segment: Segment },\n { textTrack }: { textTrack: globalThis.TextTrack }\n): Promise<void> => {\n const cues = await parseVttSegment(segment.url);\n cues.forEach((cue) => {\n if (isDuplicateCue(cue, textTrack.cues)) return;\n textTrack.addCue(cue);\n });\n};\n\n// ============================================================================\n// MAIN TASK (composite - orchestrates subtasks)\n// ============================================================================\n\n/**\n * Load text track cues task (composite - orchestrates VTT segment subtasks).\n */\nconst loadTextTrackCuesTask = async <S extends TextTrackCueLoadingState>(\n { currentState }: { currentState: S },\n context: {\n signal: AbortSignal;\n textTrack: globalThis.TextTrack;\n state: Signal<S>;\n }\n): Promise<void> => {\n const track = findSelectedTextTrack(currentState);\n if (!track || !isResolvedTrack(track)) return;\n\n const { segments } = track;\n if (segments.length === 0) return;\n\n const trackId = track.id;\n\n // Resolve segments already recorded in the state model for this track.\n // Keyed by track ID so multiple text tracks don't interfere with each other.\n const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id));\n const alreadyLoaded = segments.filter((s) => loadedIds.has(s.id));\n\n // Apply the same forward buffer window as audio/video segment loading.\n const currentTime = currentState.currentTime ?? 0;\n const segmentsToLoad = getSegmentsToLoad(segments, alreadyLoaded, currentTime).filter((s) => !loadedIds.has(s.id));\n\n if (segmentsToLoad.length === 0) return;\n\n // Execute subtasks sequentially, recording each loaded segment in state.\n for (const segment of segmentsToLoad) {\n if (context.signal.aborted) break;\n\n try {\n await loadVttSegmentTask({ segment }, { textTrack: context.textTrack });\n\n // Record the loaded segment in shared state — mirrors bufferState for\n // audio/video and supports N text tracks keyed by track ID.\n const latestState = context.state.get();\n const latest = latestState.textBufferState ?? {};\n const trackState = latest[trackId] ?? { segments: [] };\n context.state.set({\n ...latestState,\n textBufferState: {\n ...latest,\n [trackId]: { segments: [...trackState.segments, { id: segment.id }] },\n },\n } as S);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') break;\n console.error('Failed to load VTT segment:', error);\n // Continue to next segment (graceful degradation)\n }\n }\n\n // Chrome bug: after a track goes through mode='disabled' (which clears cues) and back\n // to 'showing', cues added to the track aren't activated. Re-adding all cues forces\n // Chrome to re-process them. Safe no-op in other browsers.\n // Mirrors the workaround in HlsMediaTextTracksMixin (packages/core/src/dom/media/hls/text-tracks.ts).\n if (context.textTrack.mode === 'showing' && context.textTrack.cues) {\n Array.from(context.textTrack.cues).forEach((cue) => {\n context.textTrack.addCue(cue);\n });\n }\n\n // Wait a frame before completing to allow state updates to flush\n await new Promise((resolve) => requestAnimationFrame(resolve));\n};\n\n// ============================================================================\n// STATE & OWNERS\n// ============================================================================\n\n/**\n * Loaded-segment record for a single text track.\n */\nexport interface TextTrackSegmentState {\n segments: Array<{ id: string }>;\n}\n\n/**\n * Buffer model for text track cues — keyed by track ID.\n *\n * Using a per-track-ID map (rather than fixed 'video'/'audio' keys) because\n * there can be N text tracks — one per language/subtitle variant.\n */\nexport type TextTrackBufferState = Record<string, TextTrackSegmentState>;\n\n/**\n * State shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingState {\n selectedTextTrackId?: string;\n presentation?: Presentation;\n /** Current playback position — used to gate VTT segment fetching to the forward buffer window. */\n currentTime?: number;\n /** Loaded-segment model for text tracks, keyed by track ID. */\n textBufferState?: TextTrackBufferState;\n}\n\n/**\n * Owners shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingOwners {\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Find the selected text track in the presentation.\n */\nfunction findSelectedTextTrack(state: TextTrackCueLoadingState): TextTrack | undefined {\n if (!state.presentation || !state.selectedTextTrackId) {\n return undefined;\n }\n\n const textSet = state.presentation.selectionSets.find((set) => set.type === 'text');\n if (!textSet?.switchingSets?.[0]?.tracks) {\n return undefined;\n }\n\n const track = textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId);\n\n return track as TextTrack | undefined;\n}\n\n/**\n * Get the browser's TextTrack object for the selected text track.\n *\n * Retrieves the live TextTrack interface from the track element in owners,\n * which is used for adding cues, checking mode, and managing track state.\n *\n * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track),\n * not the presentation Track metadata type.\n *\n * @param state - Current playback state (track selection)\n * @param owners - DOM owners containing track elements map\n * @returns DOM TextTrack interface or undefined if not found\n */\nfunction getSelectedTextTrackFromOwners(\n state: TextTrackCueLoadingState,\n owners: TextTrackCueLoadingOwners\n): globalThis.TextTrack | undefined {\n const trackId = state.selectedTextTrackId;\n if (!trackId || !owners.textTracks) {\n return undefined;\n }\n\n const trackElement = owners.textTracks.get(trackId);\n return trackElement?.track;\n}\n\n/**\n * Check if we can load text track cues.\n *\n * Requires:\n * - Selected text track ID exists\n * - Track elements map exists\n * - Track element exists for selected track\n */\nexport function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId);\n}\n\n/**\n * Check if we should load text track cues.\n *\n * Only load if:\n * - Track is resolved (has segments)\n * - Track has at least one segment\n * - Track element exists\n */\nexport function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n if (!canLoadTextTrackCues(state, owners)) {\n return false;\n }\n\n const track = findSelectedTextTrack(state);\n if (!track || !isResolvedTrack(track) || track.segments.length === 0) {\n return false;\n }\n\n const textTrack = getSelectedTextTrackFromOwners(state, owners);\n if (!textTrack) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Load text track cues orchestration.\n *\n * Triggers when:\n * - Text track is selected\n * - Track is resolved (has segments)\n * - Track element exists\n *\n * Fetches and parses VTT segments within the forward buffer window, then adds\n * cues to the track incrementally. Continues on segment errors to provide\n * partial subtitles.\n *\n * @example\n * const cleanup = loadTextTrackCues({ state, owners });\n */\nexport function loadTextTrackCues<S extends TextTrackCueLoadingState, O extends TextTrackCueLoadingOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n let currentTask: Promise<void> | null = null;\n let abortController: AbortController | null = null;\n let lastTrackId: string | undefined;\n\n const selectedTrackId = computed(() => state.get().selectedTextTrackId);\n\n const cleanupEffect = effect(() => {\n const s = state.get();\n const o = owners.get();\n\n // Abort any in-progress task when the selected track changes.\n // The new track's textBufferState entry will be empty, so the task\n // naturally starts fresh without needing any explicit reset.\n if (selectedTrackId.get() !== lastTrackId) {\n lastTrackId = selectedTrackId.get();\n abortController?.abort();\n currentTask = null;\n }\n\n if (currentTask) return;\n if (!shouldLoadTextTrackCues(s, o)) return;\n\n const textTrack = getSelectedTextTrackFromOwners(s, o);\n if (!textTrack) return;\n\n abortController = new AbortController();\n currentTask = loadTextTrackCuesTask(\n { currentState: s },\n { signal: abortController.signal, textTrack, state }\n ).finally(() => {\n currentTask = null;\n });\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"load-text-track-cues.js","names":[],"sources":["../../../../src/dom/features/load-text-track-cues.ts"],"sourcesContent":["import type { Reactor } from '../../core/create-machine-reactor';\nimport { createMachineReactor } from '../../core/create-machine-reactor';\nimport { computed, type Signal, untrack, update } from '../../core/signals/primitives';\nimport type { Presentation, TextTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport type { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor';\nimport { createTextTrackSegmentLoaderActor } from './text-track-segment-loader-actor';\nimport type { TextTracksActor } from './text-tracks-actor';\nimport { createTextTracksActor } from './text-tracks-actor';\n\n/**\n * FSM states for text track cue loading.\n *\n * ```\n * 'preconditions-unmet' ── mediaElement + presentation + text tracks ──→ 'setting-up'\n * ↑ |\n * │ actors created\n * │ in owners\n * │ ↓\n * ├───────────────── preconditions lost ─────────────────────── 'pending'\n * │ |\n * │ selectedTrack resolved + in DOM\n * │ ↓\n * └───────────────── preconditions lost ──────────── 'monitoring-for-loads'\n *\n * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'\n * ```\n */\nexport type LoadTextTrackCuesState = 'preconditions-unmet' | 'setting-up' | 'pending' | 'monitoring-for-loads';\n\n/**\n * State shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingState {\n selectedTextTrackId?: string;\n presentation?: Presentation;\n /** Current playback position — used to gate VTT segment fetching to the forward buffer window. */\n currentTime?: number;\n}\n\n/**\n * Owners shape for text track cue loading.\n *\n * `textTracksActor` and `segmentLoaderActor` are managed by this reactor:\n * written to owners in `'setting-up'` and reset to `undefined` on entry to\n * `'preconditions-unmet'` or `'setting-up'`. Callers are responsible for\n * destroying them when the reactor itself is destroyed (actors are not\n * auto-cleaned on `destroy()` — read them from owners and destroy explicitly).\n */\nexport interface TextTrackCueLoadingOwners {\n mediaElement?: HTMLMediaElement | undefined;\n textTracksActor?: TextTracksActor | undefined;\n segmentLoaderActor?: TextTrackSegmentLoaderActor | undefined;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction getTextTracks(presentation: Presentation | undefined) {\n return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks;\n}\n\nfunction findSelectedTrack(state: TextTrackCueLoadingState): TextTrack | undefined {\n const track = getTextTracks(state.presentation)?.find((t) => t.id === state.selectedTextTrackId);\n return track && isResolvedTrack(track) ? track : undefined;\n}\n\n/**\n * Derives the correct state from current state and owners.\n *\n * States are mutually exclusive and exhaustive:\n * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks\n * - `'setting-up'`: preconditions met; actors not yet in owners\n * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM\n * - `'monitoring-for-loads'`: selected track resolved, in DOM — ready to dispatch load messages\n */\nfunction deriveState(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): LoadTextTrackCuesState {\n if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) {\n return 'preconditions-unmet';\n }\n if (!owners.textTracksActor || !owners.segmentLoaderActor) {\n return 'setting-up';\n }\n const track = findSelectedTrack(state);\n if (!track || track.segments.length === 0) return 'pending';\n if (!Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId)) {\n return 'pending';\n }\n return 'monitoring-for-loads';\n}\n\nfunction teardownActors(owners: Signal<TextTrackCueLoadingOwners>) {\n const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get());\n // Only update owners if there are actors to clean up — avoids spurious\n // signal writes on initial startup that would re-trigger other features.\n if (!textTracksActor && !segmentLoaderActor) return;\n textTracksActor?.destroy();\n segmentLoaderActor?.destroy();\n update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined });\n}\n\n// ============================================================================\n// Main export\n// ============================================================================\n\n/**\n * Text track cue loading orchestration.\n *\n * A single `always` monitor keeps the reactor in sync with conditions.\n * Actor lifecycle is managed across two states:\n *\n * - **`'setting-up'`** — creates `TextTracksActor` and `TextTrackSegmentLoaderActor`\n * and writes them to owners. Entry resets any stale actors first.\n * - **`'preconditions-unmet'`** — destroys any actors in owners and resets them to\n * `undefined`. Handles all paths back from active states.\n * - **`'monitoring-for-loads'`** — reactive dispatch: re-runs whenever `state`\n * changes (selection, currentTime, presentation) and sends a `load` message to\n * the segment loader.\n *\n * **Destroy note:** actors written to owners are NOT auto-destroyed when\n * `reactor.destroy()` is called. Callers must read them from owners and destroy\n * them explicitly alongside the reactor.\n *\n * @example\n * const reactor = loadTextTrackCues({ state, owners });\n * // later:\n * const { textTracksActor, segmentLoaderActor } = owners.get();\n * textTracksActor?.destroy();\n * segmentLoaderActor?.destroy();\n * reactor.destroy();\n */\nexport function loadTextTrackCues<S extends TextTrackCueLoadingState, O extends TextTrackCueLoadingOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): Reactor<LoadTextTrackCuesState | 'destroying' | 'destroyed'> {\n const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));\n const currentTimeSignal = computed(() => state.get().currentTime ?? 0);\n const selectedTrackSignal = computed(() => findSelectedTrack(state.get()));\n\n return createMachineReactor<LoadTextTrackCuesState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {\n // Entry: defensive actor reset on state entry (no-op if already undefined).\n entry: () => {\n teardownActors(owners);\n },\n },\n\n 'setting-up': {\n // Entry: reset any stale actors, then create fresh ones and write to owners.\n // The fn body is automatically untracked — no untrack() needed for mediaElement.\n entry: () => {\n teardownActors(owners);\n const mediaElement = owners.get().mediaElement as HTMLMediaElement;\n const textTracksActor = createTextTracksActor(mediaElement);\n const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor);\n update(owners, { textTracksActor, segmentLoaderActor } as Partial<O>);\n },\n },\n\n pending: {},\n\n 'monitoring-for-loads': {\n // Reaction: re-runs whenever currentTime or selectedTrack changes, dispatching\n // a load message to the segment loader. owners is read with untrack() since\n // actor presence is guaranteed by deriveState when in this state.\n effects: () => {\n const currentTime = currentTimeSignal.get();\n const track = selectedTrackSignal.get()!;\n // deriveState guarantees segmentLoaderActor is in owners and findSelectedTrack\n // returns a valid resolved track when in this state. The always monitor\n // (registered before this effect) transitions us out before this re-runs\n // if either invariant ever stops holding.\n const { segmentLoaderActor } = untrack(() => owners.get());\n segmentLoaderActor!.send({ type: 'load', track, currentTime });\n },\n },\n },\n });\n}\n"],"mappings":";;;;;;AA2DA,SAAS,cAAc,cAAwC;AAC7D,QAAO,cAAc,eAAe,MAAM,MAAM,EAAE,SAAS,OAAO,EAAE,cAAc,IAAI;;AAGxF,SAAS,kBAAkB,OAAwD;CACjF,MAAM,QAAQ,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,EAAE,OAAO,MAAM,oBAAoB;AAChG,QAAO,SAAS,gBAAgB,MAAM,GAAG,QAAQ,KAAA;;;;;;;;;;;AAYnD,SAAS,YAAY,OAAiC,QAA2D;AAC/G,KAAI,CAAC,OAAO,gBAAgB,CAAC,cAAc,MAAM,aAAa,EAAE,OAC9D,QAAO;AAET,KAAI,CAAC,OAAO,mBAAmB,CAAC,OAAO,mBACrC,QAAO;CAET,MAAM,QAAQ,kBAAkB,MAAM;AACtC,KAAI,CAAC,SAAS,MAAM,SAAS,WAAW,EAAG,QAAO;AAClD,KAAI,CAAC,MAAM,KAAK,OAAO,aAAa,WAAW,CAAC,MAAM,MAAM,EAAE,OAAO,MAAM,oBAAoB,CAC7F,QAAO;AAET,QAAO;;AAGT,SAAS,eAAe,QAA2C;CACjE,MAAM,EAAE,iBAAiB,uBAAuB,cAAc,OAAO,KAAK,CAAC;AAG3E,KAAI,CAAC,mBAAmB,CAAC,mBAAoB;AAC7C,kBAAiB,SAAS;AAC1B,qBAAoB,SAAS;AAC7B,QAAO,QAAQ;EAAE,iBAAiB,KAAA;EAAW,oBAAoB,KAAA;EAAW,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiC/E,SAAgB,kBAA2F,EACzG,OACA,UAI+D;CAC/D,MAAM,qBAAqB,eAAe,YAAY,MAAM,KAAK,EAAE,OAAO,KAAK,CAAC,CAAC;CACjF,MAAM,oBAAoB,eAAe,MAAM,KAAK,CAAC,eAAe,EAAE;CACtE,MAAM,sBAAsB,eAAe,kBAAkB,MAAM,KAAK,CAAC,CAAC;AAE1E,QAAO,qBAA6C;EAClD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAErB,aAAa;AACX,mBAAe,OAAO;MAEzB;GAED,cAAc,EAGZ,aAAa;AACX,mBAAe,OAAO;IACtB,MAAM,eAAe,OAAO,KAAK,CAAC;IAClC,MAAM,kBAAkB,sBAAsB,aAAa;AAE3D,WAAO,QAAQ;KAAE;KAAiB,oBADP,kCAAkC,gBAAgB;KACvB,CAAe;MAExE;GAED,SAAS,EAAE;GAEX,wBAAwB,EAItB,eAAe;IACb,MAAM,cAAc,kBAAkB,KAAK;IAC3C,MAAM,QAAQ,oBAAoB,KAAK;IAKvC,MAAM,EAAE,uBAAuB,cAAc,OAAO,KAAK,CAAC;AAC1D,uBAAoB,KAAK;KAAE,MAAM;KAAQ;KAAO;KAAa,CAAC;MAEjE;GACF;EACF,CAAC"}