@videojs/spf 10.0.0-beta.26 → 10.0.0-beta.28

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 (238) hide show
  1. package/dist/default/core/signals/when.js +38 -0
  2. package/dist/default/core/signals/when.js.map +1 -0
  3. package/dist/default/core/tasks/delayed-reschedule.js +31 -0
  4. package/dist/default/core/tasks/delayed-reschedule.js.map +1 -0
  5. package/dist/default/core/tasks/task.js +133 -3
  6. package/dist/default/core/tasks/task.js.map +1 -1
  7. package/dist/default/hls-audio.js +3 -0
  8. package/dist/default/hls-background-video.js +3 -0
  9. package/dist/default/hls-video.js +4 -0
  10. package/dist/default/hls.js +6 -5
  11. package/dist/default/media/dom/capabilities.js +2 -0
  12. package/dist/default/media/dom/capabilities.js.map +1 -1
  13. package/dist/default/media/errors.js +62 -0
  14. package/dist/default/media/errors.js.map +1 -0
  15. package/dist/default/media/hls/parse-media-playlist.js +167 -16
  16. package/dist/default/media/hls/parse-media-playlist.js.map +1 -1
  17. package/dist/default/media/hls/parse-multivariant.js +14 -3
  18. package/dist/default/media/hls/parse-multivariant.js.map +1 -1
  19. package/dist/default/media/hls/reload-policy.js +71 -0
  20. package/dist/default/media/hls/reload-policy.js.map +1 -0
  21. package/dist/default/media/live-window.js +36 -0
  22. package/dist/default/media/live-window.js.map +1 -0
  23. package/dist/default/media/primitives/select-tracks.js +1 -24
  24. package/dist/default/media/primitives/select-tracks.js.map +1 -1
  25. package/dist/default/media/types/index.js +15 -1
  26. package/dist/default/media/types/index.js.map +1 -1
  27. package/dist/default/media/utils/tracks.js +14 -0
  28. package/dist/default/media/utils/tracks.js.map +1 -1
  29. package/dist/default/mux-audio.js +3 -0
  30. package/dist/default/mux-background-video.js +3 -0
  31. package/dist/default/mux-video.js +3 -0
  32. package/dist/default/playback/adapters/hls-audio/adapter.js +186 -0
  33. package/dist/default/playback/adapters/hls-audio/adapter.js.map +1 -0
  34. package/dist/default/playback/adapters/hls-audio/media.js +9 -0
  35. package/dist/default/playback/adapters/hls-audio/media.js.map +1 -0
  36. package/dist/default/playback/adapters/hls-background-video/adapter.js +123 -0
  37. package/dist/default/playback/adapters/hls-background-video/adapter.js.map +1 -0
  38. package/dist/default/playback/adapters/hls-background-video/host.js +60 -0
  39. package/dist/default/playback/adapters/hls-background-video/host.js.map +1 -0
  40. package/dist/default/playback/adapters/hls-background-video/media.js +21 -0
  41. package/dist/default/playback/adapters/hls-background-video/media.js.map +1 -0
  42. package/dist/default/playback/adapters/hls-video/adapter.js +316 -0
  43. package/dist/default/playback/adapters/hls-video/adapter.js.map +1 -0
  44. package/dist/default/playback/adapters/hls-video/error-surface.js +51 -0
  45. package/dist/default/playback/adapters/hls-video/error-surface.js.map +1 -0
  46. package/dist/default/playback/adapters/hls-video/media-tracks.js +126 -0
  47. package/dist/default/playback/adapters/hls-video/media-tracks.js.map +1 -0
  48. package/dist/default/playback/adapters/hls-video/media.js +11 -0
  49. package/dist/default/playback/adapters/hls-video/media.js.map +1 -0
  50. package/dist/default/playback/adapters/mux-audio/media.js +26 -0
  51. package/dist/default/playback/adapters/mux-audio/media.js.map +1 -0
  52. package/dist/default/playback/adapters/mux-video/adapter.js +92 -0
  53. package/dist/default/playback/adapters/mux-video/adapter.js.map +1 -0
  54. package/dist/default/playback/adapters/mux-video/media.js +23 -0
  55. package/dist/default/playback/adapters/mux-video/media.js.map +1 -0
  56. package/dist/default/playback/behaviors/collect-errors.js +77 -0
  57. package/dist/default/playback/behaviors/collect-errors.js.map +1 -0
  58. package/dist/default/playback/behaviors/dom/end-of-stream.js +1 -0
  59. package/dist/default/playback/behaviors/dom/end-of-stream.js.map +1 -1
  60. package/dist/default/playback/behaviors/dom/seek-to-live-edge.js +132 -0
  61. package/dist/default/playback/behaviors/dom/seek-to-live-edge.js.map +1 -0
  62. package/dist/default/playback/behaviors/dom/sync-live-seekable-range.js +26 -0
  63. package/dist/default/playback/behaviors/dom/sync-live-seekable-range.js.map +1 -0
  64. package/dist/default/playback/behaviors/dom/update-mediasource-duration.js +29 -7
  65. package/dist/default/playback/behaviors/dom/update-mediasource-duration.js.map +1 -1
  66. package/dist/default/playback/behaviors/establish-start-media-time.js +78 -4
  67. package/dist/default/playback/behaviors/establish-start-media-time.js.map +1 -1
  68. package/dist/default/playback/behaviors/resolve-track.js +29 -8
  69. package/dist/default/playback/behaviors/resolve-track.js.map +1 -1
  70. package/dist/default/playback/behaviors/track-switching.js +18 -9
  71. package/dist/default/playback/behaviors/track-switching.js.map +1 -1
  72. package/dist/default/playback/engines/hls/engine-audio-only.js +9 -5
  73. package/dist/default/playback/engines/hls/engine-audio-only.js.map +1 -1
  74. package/dist/{dev/playback/engines/background-video/engine.js → default/playback/engines/hls/engine-background-video.js} +3 -3
  75. package/dist/default/playback/engines/hls/engine-background-video.js.map +1 -0
  76. package/dist/default/playback/engines/hls/engine.js +19 -6
  77. package/dist/default/playback/engines/hls/engine.js.map +1 -1
  78. package/dist/default/playback/primitives/error-messages.js +39 -0
  79. package/dist/default/playback/primitives/error-messages.js.map +1 -0
  80. package/dist/default/playback/primitives/live-window.js +66 -0
  81. package/dist/default/playback/primitives/live-window.js.map +1 -0
  82. package/dist/default/playback/primitives/report-track-conditions.js +86 -0
  83. package/dist/default/playback/primitives/report-track-conditions.js.map +1 -0
  84. package/dist/dev/core/signals/when.js +38 -0
  85. package/dist/dev/core/signals/when.js.map +1 -0
  86. package/dist/dev/core/tasks/delayed-reschedule.js +31 -0
  87. package/dist/dev/core/tasks/delayed-reschedule.js.map +1 -0
  88. package/dist/dev/core/tasks/task.d.ts +52 -1
  89. package/dist/dev/core/tasks/task.d.ts.map +1 -1
  90. package/dist/dev/core/tasks/task.js +133 -3
  91. package/dist/dev/core/tasks/task.js.map +1 -1
  92. package/dist/dev/hls-audio.d.ts +3 -0
  93. package/dist/dev/hls-audio.js +3 -0
  94. package/dist/dev/hls-background-video.d.ts +3 -0
  95. package/dist/dev/hls-background-video.js +3 -0
  96. package/dist/dev/hls-video.d.ts +5 -0
  97. package/dist/dev/hls-video.js +4 -0
  98. package/dist/dev/hls.d.ts +6 -5
  99. package/dist/dev/hls.js +6 -5
  100. package/dist/dev/media/dom/capabilities.js +2 -0
  101. package/dist/dev/media/dom/capabilities.js.map +1 -1
  102. package/dist/dev/media/errors.d.ts +82 -0
  103. package/dist/dev/media/errors.d.ts.map +1 -0
  104. package/dist/dev/media/errors.js +62 -0
  105. package/dist/dev/media/errors.js.map +1 -0
  106. package/dist/dev/media/hls/parse-media-playlist.js +167 -16
  107. package/dist/dev/media/hls/parse-media-playlist.js.map +1 -1
  108. package/dist/dev/media/hls/parse-multivariant.js +14 -3
  109. package/dist/dev/media/hls/parse-multivariant.js.map +1 -1
  110. package/dist/dev/media/hls/reload-policy.js +71 -0
  111. package/dist/dev/media/hls/reload-policy.js.map +1 -0
  112. package/dist/dev/media/live-window.js +36 -0
  113. package/dist/dev/media/live-window.js.map +1 -0
  114. package/dist/dev/media/primitives/select-tracks.js +1 -24
  115. package/dist/dev/media/primitives/select-tracks.js.map +1 -1
  116. package/dist/dev/media/types/index.d.ts +125 -9
  117. package/dist/dev/media/types/index.d.ts.map +1 -1
  118. package/dist/dev/media/types/index.js +15 -1
  119. package/dist/dev/media/types/index.js.map +1 -1
  120. package/dist/dev/media/utils/tracks.js +14 -0
  121. package/dist/dev/media/utils/tracks.js.map +1 -1
  122. package/dist/dev/mux-audio.d.ts +4 -0
  123. package/dist/dev/mux-audio.js +3 -0
  124. package/dist/dev/mux-background-video.d.ts +3 -0
  125. package/dist/dev/mux-background-video.js +3 -0
  126. package/dist/dev/mux-video.d.ts +4 -0
  127. package/dist/dev/mux-video.js +3 -0
  128. package/dist/dev/playback/adapters/hls-audio/adapter.d.ts +54 -0
  129. package/dist/dev/playback/adapters/hls-audio/adapter.d.ts.map +1 -0
  130. package/dist/dev/playback/adapters/hls-audio/adapter.js +186 -0
  131. package/dist/dev/playback/adapters/hls-audio/adapter.js.map +1 -0
  132. package/dist/dev/playback/adapters/hls-audio/media.d.ts +10 -0
  133. package/dist/dev/playback/adapters/hls-audio/media.d.ts.map +1 -0
  134. package/dist/dev/playback/adapters/hls-audio/media.js +9 -0
  135. package/dist/dev/playback/adapters/hls-audio/media.js.map +1 -0
  136. package/dist/dev/playback/adapters/hls-background-video/adapter.d.ts +59 -0
  137. package/dist/dev/playback/adapters/hls-background-video/adapter.d.ts.map +1 -0
  138. package/dist/dev/playback/adapters/hls-background-video/adapter.js +123 -0
  139. package/dist/dev/playback/adapters/hls-background-video/adapter.js.map +1 -0
  140. package/dist/dev/playback/adapters/hls-background-video/host.d.ts +38 -0
  141. package/dist/dev/playback/adapters/hls-background-video/host.d.ts.map +1 -0
  142. package/dist/dev/playback/adapters/hls-background-video/host.js +60 -0
  143. package/dist/dev/playback/adapters/hls-background-video/host.js.map +1 -0
  144. package/dist/dev/playback/adapters/hls-background-video/media.d.ts +20 -0
  145. package/dist/dev/playback/adapters/hls-background-video/media.d.ts.map +1 -0
  146. package/dist/dev/playback/adapters/hls-background-video/media.js +21 -0
  147. package/dist/dev/playback/adapters/hls-background-video/media.js.map +1 -0
  148. package/dist/dev/playback/adapters/hls-video/adapter.d.ts +63 -0
  149. package/dist/dev/playback/adapters/hls-video/adapter.d.ts.map +1 -0
  150. package/dist/dev/playback/adapters/hls-video/adapter.js +316 -0
  151. package/dist/dev/playback/adapters/hls-video/adapter.js.map +1 -0
  152. package/dist/dev/playback/adapters/hls-video/error-surface.d.ts +18 -0
  153. package/dist/dev/playback/adapters/hls-video/error-surface.d.ts.map +1 -0
  154. package/dist/dev/playback/adapters/hls-video/error-surface.js +51 -0
  155. package/dist/dev/playback/adapters/hls-video/error-surface.js.map +1 -0
  156. package/dist/dev/playback/adapters/hls-video/media-tracks.d.ts +22 -0
  157. package/dist/dev/playback/adapters/hls-video/media-tracks.d.ts.map +1 -0
  158. package/dist/dev/playback/adapters/hls-video/media-tracks.js +126 -0
  159. package/dist/dev/playback/adapters/hls-video/media-tracks.js.map +1 -0
  160. package/dist/dev/playback/adapters/hls-video/media.d.ts +10 -0
  161. package/dist/dev/playback/adapters/hls-video/media.d.ts.map +1 -0
  162. package/dist/dev/playback/adapters/hls-video/media.js +11 -0
  163. package/dist/dev/playback/adapters/hls-video/media.js.map +1 -0
  164. package/dist/dev/playback/adapters/mux-audio/media.d.ts +28 -0
  165. package/dist/dev/playback/adapters/mux-audio/media.d.ts.map +1 -0
  166. package/dist/dev/playback/adapters/mux-audio/media.js +26 -0
  167. package/dist/dev/playback/adapters/mux-audio/media.js.map +1 -0
  168. package/dist/dev/playback/adapters/mux-video/adapter.d.ts +33 -0
  169. package/dist/dev/playback/adapters/mux-video/adapter.d.ts.map +1 -0
  170. package/dist/dev/playback/adapters/mux-video/adapter.js +92 -0
  171. package/dist/dev/playback/adapters/mux-video/adapter.js.map +1 -0
  172. package/dist/dev/playback/adapters/mux-video/media.d.ts +24 -0
  173. package/dist/dev/playback/adapters/mux-video/media.d.ts.map +1 -0
  174. package/dist/dev/playback/adapters/mux-video/media.js +23 -0
  175. package/dist/dev/playback/adapters/mux-video/media.js.map +1 -0
  176. package/dist/dev/playback/behaviors/collect-errors.js +77 -0
  177. package/dist/dev/playback/behaviors/collect-errors.js.map +1 -0
  178. package/dist/dev/playback/behaviors/dom/end-of-stream.js +1 -0
  179. package/dist/dev/playback/behaviors/dom/end-of-stream.js.map +1 -1
  180. package/dist/dev/playback/behaviors/dom/seek-to-live-edge.js +132 -0
  181. package/dist/dev/playback/behaviors/dom/seek-to-live-edge.js.map +1 -0
  182. package/dist/dev/playback/behaviors/dom/sync-live-seekable-range.js +26 -0
  183. package/dist/dev/playback/behaviors/dom/sync-live-seekable-range.js.map +1 -0
  184. package/dist/dev/playback/behaviors/dom/update-mediasource-duration.js +29 -7
  185. package/dist/dev/playback/behaviors/dom/update-mediasource-duration.js.map +1 -1
  186. package/dist/dev/playback/behaviors/establish-start-media-time.d.ts +5 -0
  187. package/dist/dev/playback/behaviors/establish-start-media-time.d.ts.map +1 -1
  188. package/dist/dev/playback/behaviors/establish-start-media-time.js +78 -4
  189. package/dist/dev/playback/behaviors/establish-start-media-time.js.map +1 -1
  190. package/dist/dev/playback/behaviors/resolve-track.js +29 -8
  191. package/dist/dev/playback/behaviors/resolve-track.js.map +1 -1
  192. package/dist/dev/playback/behaviors/track-switching.js +18 -9
  193. package/dist/dev/playback/behaviors/track-switching.js.map +1 -1
  194. package/dist/dev/playback/engines/hls/engine-audio-only.d.ts +34 -17
  195. package/dist/dev/playback/engines/hls/engine-audio-only.d.ts.map +1 -1
  196. package/dist/dev/playback/engines/hls/engine-audio-only.js +9 -5
  197. package/dist/dev/playback/engines/hls/engine-audio-only.js.map +1 -1
  198. package/dist/dev/playback/engines/{background-video/engine.d.ts → hls/engine-background-video.d.ts} +7 -7
  199. package/dist/dev/playback/engines/hls/engine-background-video.d.ts.map +1 -0
  200. package/dist/{default/playback/engines/background-video/engine.js → dev/playback/engines/hls/engine-background-video.js} +3 -3
  201. package/dist/dev/playback/engines/hls/engine-background-video.js.map +1 -0
  202. package/dist/dev/playback/engines/hls/engine.d.ts +43 -15
  203. package/dist/dev/playback/engines/hls/engine.d.ts.map +1 -1
  204. package/dist/dev/playback/engines/hls/engine.js +19 -6
  205. package/dist/dev/playback/engines/hls/engine.js.map +1 -1
  206. package/dist/dev/playback/primitives/error-messages.js +39 -0
  207. package/dist/dev/playback/primitives/error-messages.js.map +1 -0
  208. package/dist/dev/playback/primitives/live-window.js +66 -0
  209. package/dist/dev/playback/primitives/live-window.js.map +1 -0
  210. package/dist/dev/playback/primitives/report-track-conditions.d.ts +11 -0
  211. package/dist/dev/playback/primitives/report-track-conditions.d.ts.map +1 -0
  212. package/dist/dev/playback/primitives/report-track-conditions.js +86 -0
  213. package/dist/dev/playback/primitives/report-track-conditions.js.map +1 -0
  214. package/package.json +32 -6
  215. package/dist/default/background-video.js +0 -3
  216. package/dist/default/playback/engines/background-video/adapter.js +0 -158
  217. package/dist/default/playback/engines/background-video/adapter.js.map +0 -1
  218. package/dist/default/playback/engines/background-video/engine.js.map +0 -1
  219. package/dist/default/playback/engines/hls/adapter-audio-only.js +0 -124
  220. package/dist/default/playback/engines/hls/adapter-audio-only.js.map +0 -1
  221. package/dist/default/playback/engines/hls/adapter.js +0 -120
  222. package/dist/default/playback/engines/hls/adapter.js.map +0 -1
  223. package/dist/dev/background-video.d.ts +0 -3
  224. package/dist/dev/background-video.js +0 -3
  225. package/dist/dev/playback/engines/background-video/adapter.d.ts +0 -59
  226. package/dist/dev/playback/engines/background-video/adapter.d.ts.map +0 -1
  227. package/dist/dev/playback/engines/background-video/adapter.js +0 -158
  228. package/dist/dev/playback/engines/background-video/adapter.js.map +0 -1
  229. package/dist/dev/playback/engines/background-video/engine.d.ts.map +0 -1
  230. package/dist/dev/playback/engines/background-video/engine.js.map +0 -1
  231. package/dist/dev/playback/engines/hls/adapter-audio-only.d.ts +0 -46
  232. package/dist/dev/playback/engines/hls/adapter-audio-only.d.ts.map +0 -1
  233. package/dist/dev/playback/engines/hls/adapter-audio-only.js +0 -124
  234. package/dist/dev/playback/engines/hls/adapter-audio-only.js.map +0 -1
  235. package/dist/dev/playback/engines/hls/adapter.d.ts +0 -42
  236. package/dist/dev/playback/engines/hls/adapter.d.ts.map +0 -1
  237. package/dist/dev/playback/engines/hls/adapter.js +0 -120
  238. package/dist/dev/playback/engines/hls/adapter.js.map +0 -1
@@ -0,0 +1,132 @@
1
+ import { computed, peek } from "../../../core/signals/primitives.js";
2
+ import { createMachineReactor } from "../../../core/reactors/create-machine-reactor.js";
3
+ import { getLiveEdge } from "../../primitives/live-window.js";
4
+ import { listen } from "@videojs/utils/dom";
5
+ //#region src/playback/behaviors/dom/seek-to-live-edge.ts
6
+ /**
7
+ * Keep the playhead in the live window, via a two-state reactor gated on the
8
+ * preconditions for "we know where live is":
9
+ *
10
+ * - **`inactive`** — no media element, or no live edge (`getLiveEdge` is `null`:
11
+ * VOD, ended, or unresolved). Idle.
12
+ * - **`live`** — preconditions met. `entry` commands `state.startPosition` once
13
+ * to the target live latency behind the edge (clamped to the window start) so
14
+ * playback begins near the edge and the loader dispatches an in-window range;
15
+ * `effects` runs the window-exit guard.
16
+ *
17
+ * A derivable live edge is itself the establishment gate: segment placement is
18
+ * settled at parse time — the reference track's local placement *is* the
19
+ * presentation timeline, and every other track's first parse is held until the
20
+ * anchor is stamped (`resolve-track`'s gate + `establishStartMediaTime`) — so
21
+ * any window derived from resolved segments is already final, with no separate
22
+ * anchor signal to wait on (see
23
+ * `internal/design/spf/live-presentation-timeline-model.md`).
24
+ *
25
+ * The two pieces split along the axis a future DVR / EVENT mode will care about:
26
+ * the **one-time start position** (`entry`) is the *live-specific* behavior — start
27
+ * near the edge on load; a DVR mode makes it conditional (start in place). The
28
+ * **window-exit guard** (`effects`) is the *general windowed-live* behavior —
29
+ * applies to sliding-window live, DVR, and EVENT alike. Because the command is an
30
+ * `entry`, it fires once per entry into `live`; a source change exits to
31
+ * `inactive`, so the next source re-commands (no closure latch to reset). The
32
+ * guard stays a direct seek — it is recurring, while `startPosition` is a
33
+ * self-clearing one-shot.
34
+ *
35
+ * Window-exit guard: while playing (not paused), reposition to the live edge
36
+ * when the playhead has fallen behind the window start — including when a seek
37
+ * to a now-evicted position has stranded the playhead (such a seek can never
38
+ * settle, so we rescue rather than wait on it). Two triggers:
39
+ * the **window-update re-fire** (the guard reads the live edge, so each reload /
40
+ * slide re-runs it — this catches a stall, where `timeupdate` stops but the
41
+ * playlist keeps reloading) and a **`play` listener** for immediate reactivity on
42
+ * resume, since the reload interval can be seconds. `play`, not `playing`: after
43
+ * a long pause the playhead sits behind the window at an unseekable position,
44
+ * where the browser stalls and `playing` never fires; `play` fires on the
45
+ * paused→false transition regardless, so we snap before the stall. In-window
46
+ * pause / DVR scrub-back are left untouched.
47
+ *
48
+ * The latency comes from the injected `resolveLiveLatency` seam (HLS:
49
+ * `HOLD-BACK`), so this behavior carries no delivery-format specifics.
50
+ * `applyStartPosition` performs the seek, gated on `loadedmetadata` — which
51
+ * implies an open MediaSource and hence a declared seekable range (a seek outside
52
+ * `seekable` is clamped) — so this behavior needs no MediaSource precondition of
53
+ * its own.
54
+ */
55
+ /**
56
+ * Tolerance (seconds) around the window edges before the guard repositions, so
57
+ * boundary / floating-point noise doesn't trigger a spurious seek.
58
+ */
59
+ const REPOSITION_TOLERANCE = .1;
60
+ /**
61
+ * `'live'` once the preconditions hold: a media element and a derivable live edge
62
+ * (whose placement is final by construction — see the module docstring).
63
+ * `'inactive'` otherwise.
64
+ *
65
+ * Deliberately narrow: every signal here can flip the reactor out of and back into
66
+ * `live`, re-firing `entry`. Neither blinks mid-source — the edge can't, because
67
+ * `liveWindowForType` falls back to any resolved track of the type — so `entry`
68
+ * fires once per source without a latch, and a live reload (same source, slid
69
+ * window) correctly doesn't re-command.
70
+ *
71
+ * The flip side: the presentation *url* is not part of this state, so replacing one
72
+ * already-resolved live presentation with another would stay in `live` and never
73
+ * command a start position for the new source. Unreachable today — every writer
74
+ * sets `{ url }` (unresolved) or `undefined` first, so a source change always
75
+ * transits `inactive`. Supporting a seeded pre-resolved presentation (via
76
+ * `initialState`) that later changes would mean folding the url in here.
77
+ */
78
+ function deriveState(mediaElement, edge) {
79
+ return mediaElement && edge ? "live" : "inactive";
80
+ }
81
+ function seekToLiveEdgeSetup({ state, context, config }) {
82
+ const derivedStateSignal = computed(() => deriveState(context.mediaElement.get(), getLiveEdge({
83
+ state,
84
+ config
85
+ })));
86
+ return createMachineReactor({
87
+ initial: "inactive",
88
+ monitor: () => derivedStateSignal.get(),
89
+ states: {
90
+ inactive: {},
91
+ live: {
92
+ entry: () => {
93
+ const mediaElement = context.mediaElement.get();
94
+ const { liveEdgeStart } = getLiveEdge({
95
+ state,
96
+ config
97
+ });
98
+ if (mediaElement.currentTime >= liveEdgeStart) return;
99
+ state.startPosition.set(liveEdgeStart);
100
+ },
101
+ effects: () => {
102
+ const { start: windowStart, liveEdgeStart } = getLiveEdge({
103
+ state,
104
+ config
105
+ });
106
+ const mediaElement = peek(context.mediaElement);
107
+ const reposition = () => {
108
+ if (mediaElement.paused) return;
109
+ if (mediaElement.currentTime < windowStart - REPOSITION_TOLERANCE) mediaElement.currentTime = liveEdgeStart;
110
+ };
111
+ reposition();
112
+ return listen(mediaElement, "play", reposition);
113
+ }
114
+ }
115
+ }
116
+ });
117
+ }
118
+ /**
119
+ * Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares
120
+ * only `presentation` + `startPosition` in stateKeys while reading
121
+ * `selectedVideoTrackId` / `selectedAudioTrackId` defensively (contributed by the
122
+ * switch* behaviors), so it composes without a stateKeys/type conflict.
123
+ */
124
+ const seekToLiveEdge = {
125
+ stateKeys: ["presentation", "startPosition"],
126
+ contextKeys: ["mediaElement"],
127
+ setup: seekToLiveEdgeSetup
128
+ };
129
+ //#endregion
130
+ export { seekToLiveEdge };
131
+
132
+ //# sourceMappingURL=seek-to-live-edge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seek-to-live-edge.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/seek-to-live-edge.ts"],"sourcesContent":["/**\n * Keep the playhead in the live window, via a two-state reactor gated on the\n * preconditions for \"we know where live is\":\n *\n * - **`inactive`** — no media element, or no live edge (`getLiveEdge` is `null`:\n * VOD, ended, or unresolved). Idle.\n * - **`live`** — preconditions met. `entry` commands `state.startPosition` once\n * to the target live latency behind the edge (clamped to the window start) so\n * playback begins near the edge and the loader dispatches an in-window range;\n * `effects` runs the window-exit guard.\n *\n * A derivable live edge is itself the establishment gate: segment placement is\n * settled at parse time — the reference track's local placement *is* the\n * presentation timeline, and every other track's first parse is held until the\n * anchor is stamped (`resolve-track`'s gate + `establishStartMediaTime`) — so\n * any window derived from resolved segments is already final, with no separate\n * anchor signal to wait on (see\n * `internal/design/spf/live-presentation-timeline-model.md`).\n *\n * The two pieces split along the axis a future DVR / EVENT mode will care about:\n * the **one-time start position** (`entry`) is the *live-specific* behavior — start\n * near the edge on load; a DVR mode makes it conditional (start in place). The\n * **window-exit guard** (`effects`) is the *general windowed-live* behavior —\n * applies to sliding-window live, DVR, and EVENT alike. Because the command is an\n * `entry`, it fires once per entry into `live`; a source change exits to\n * `inactive`, so the next source re-commands (no closure latch to reset). The\n * guard stays a direct seek — it is recurring, while `startPosition` is a\n * self-clearing one-shot.\n *\n * Window-exit guard: while playing (not paused), reposition to the live edge\n * when the playhead has fallen behind the window start — including when a seek\n * to a now-evicted position has stranded the playhead (such a seek can never\n * settle, so we rescue rather than wait on it). Two triggers:\n * the **window-update re-fire** (the guard reads the live edge, so each reload /\n * slide re-runs it — this catches a stall, where `timeupdate` stops but the\n * playlist keeps reloading) and a **`play` listener** for immediate reactivity on\n * resume, since the reload interval can be seconds. `play`, not `playing`: after\n * a long pause the playhead sits behind the window at an unseekable position,\n * where the browser stalls and `playing` never fires; `play` fires on the\n * paused→false transition regardless, so we snap before the stall. In-window\n * pause / DVR scrub-back are left untouched.\n *\n * The latency comes from the injected `resolveLiveLatency` seam (HLS:\n * `HOLD-BACK`), so this behavior carries no delivery-format specifics.\n * `applyStartPosition` performs the seek, gated on `loadedmetadata` — which\n * implies an open MediaSource and hence a declared seekable range (a seek outside\n * `seekable` is clamped) — so this behavior needs no MediaSource precondition of\n * its own.\n */\nimport { listen } from '@videojs/utils/dom';\nimport type { Behavior } from '../../../core/composition/create-composition';\nimport { createMachineReactor, type Reactor } from '../../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal } from '../../../core/signals/primitives';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\nimport { getLiveEdge, type LiveEdge, type ResolveLiveLatency } from '../../primitives/live-window';\n\n/**\n * Tolerance (seconds) around the window edges before the guard repositions, so\n * boundary / floating-point noise doesn't trigger a spurious seek.\n */\nconst REPOSITION_TOLERANCE = 0.1;\n\nexport interface SeekToLiveEdgeState {\n presentation?: MaybeResolvedPresentation;\n /**\n * One-shot start-position command in presentation-timeline seconds. Written\n * here on entry into `live`; consumed (cleared) by `applyStartPosition`.\n */\n startPosition?: number;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\nexport interface SeekToLiveEdgeContext {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\nexport interface SeekToLiveEdgeConfig {\n /**\n * Resolve the target live latency (seconds the playhead should trail the live\n * edge) for the timeline-bearing track. Injected by the engine so the latency\n * rule stays format-specific (HLS: `HOLD-BACK`, default 3× target duration;\n * DASH would read `suggestedPresentationDelay`) while this behavior stays\n * neutral. Absent → `0` (seek straight to the edge).\n */\n resolveLiveLatency?: ResolveLiveLatency;\n}\n\ntype SeekToLiveEdgeFsmState = 'inactive' | 'live';\n\n/**\n * `'live'` once the preconditions hold: a media element and a derivable live edge\n * (whose placement is final by construction — see the module docstring).\n * `'inactive'` otherwise.\n *\n * Deliberately narrow: every signal here can flip the reactor out of and back into\n * `live`, re-firing `entry`. Neither blinks mid-source — the edge can't, because\n * `liveWindowForType` falls back to any resolved track of the type — so `entry`\n * fires once per source without a latch, and a live reload (same source, slid\n * window) correctly doesn't re-command.\n *\n * The flip side: the presentation *url* is not part of this state, so replacing one\n * already-resolved live presentation with another would stay in `live` and never\n * command a start position for the new source. Unreachable today — every writer\n * sets `{ url }` (unresolved) or `undefined` first, so a source change always\n * transits `inactive`. Supporting a seeded pre-resolved presentation (via\n * `initialState`) that later changes would mean folding the url in here.\n */\nfunction deriveState(mediaElement: HTMLMediaElement | undefined, edge: LiveEdge | null): SeekToLiveEdgeFsmState {\n return mediaElement && edge ? 'live' : 'inactive';\n}\n\nfunction seekToLiveEdgeSetup({\n state,\n context,\n config,\n}: {\n state: {\n presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;\n startPosition: Signal<SeekToLiveEdgeState['startPosition']>;\n selectedVideoTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedVideoTrackId']>;\n selectedAudioTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedAudioTrackId']>;\n };\n context: {\n mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;\n };\n config?: SeekToLiveEdgeConfig;\n}): Reactor<SeekToLiveEdgeFsmState | 'destroying' | 'destroyed'> {\n const derivedStateSignal = computed(() => deriveState(context.mediaElement.get(), getLiveEdge({ state, config })));\n\n return createMachineReactor<SeekToLiveEdgeFsmState>({\n initial: 'inactive',\n monitor: () => derivedStateSignal.get(),\n states: {\n inactive: {},\n\n live: {\n // One-time-per-source start position so the loader dispatches an in-window\n // range and preload shows the right frame. The window-exit guard below is\n // the separate, continuous rule. Live-specific — a future DVR mode skips\n // even this first command (starts in place).\n entry: () => {\n // The monitor guarantees a media element + live edge while in `live`.\n const mediaElement = context.mediaElement.get()!;\n const { liveEdgeStart } = getLiveEdge({ state, config })!;\n // Never command backwards: seeding `state.currentTime` behind a playhead\n // already at/past the edge would drag the loaders back.\n if (mediaElement.currentTime >= liveEdgeStart) return;\n state.startPosition.set(liveEdgeStart);\n },\n\n // Window-exit guard. Repositions to the live edge when the playhead has\n // fallen behind the window start, while playing. Two triggers: this\n // effect's window-update re-fire (it reads the live edge, so each reload\n // re-runs it — catches a stall, where `timeupdate` is silent) and a\n // `play` listener for immediate reactivity on resume.\n effects: () => {\n // Read the live edge first — the only tracked dependency — so window\n // slides keep re-firing this effect even while paused. Bailing before\n // it (on paused) would drop the dependency and the effect would go\n // inert, missing the next slide. The monitor guarantees the edge while\n // in `live`; the media element is read untracked (the window update,\n // not element identity, is the re-fire trigger).\n const { start: windowStart, liveEdgeStart } = getLiveEdge({ state, config })!;\n const mediaElement = peek(context.mediaElement)!;\n const reposition = () => {\n // Don't yank a paused viewer. We deliberately DON'T bail on\n // `mediaElement.seeking`: a seek whose target sits behind the window\n // start can never complete (the data has slid out of the window and\n // been evicted), so `seeking` would stay true forever and strand the\n // playhead — the very stall this guard exists to rescue. The\n // `currentTime < windowStart` test is itself the precise\n // discriminator: an in-window DVR scrub-back lands at\n // `currentTime >= windowStart` and never trips it, so only a stuck\n // out-of-window seek is repositioned.\n if (mediaElement.paused) return;\n if (mediaElement.currentTime < windowStart - REPOSITION_TOLERANCE) {\n mediaElement.currentTime = liveEdgeStart;\n }\n };\n reposition();\n // `play` (not `playing`): a slid-past playhead is at an unseekable\n // position where `playing` would stall and never fire; `play` fires on\n // unpause regardless, so we snap before the stall.\n return listen(mediaElement, 'play', reposition);\n },\n },\n },\n });\n}\n\n/**\n * Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares\n * only `presentation` + `startPosition` in stateKeys while reading\n * `selectedVideoTrackId` / `selectedAudioTrackId` defensively (contributed by the\n * switch* behaviors), so it composes without a stateKeys/type conflict.\n */\nexport const seekToLiveEdge: Behavior<\n {\n presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;\n startPosition: Signal<SeekToLiveEdgeState['startPosition']>;\n },\n { mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']> },\n SeekToLiveEdgeConfig\n> = {\n stateKeys: ['presentation', 'startPosition'],\n contextKeys: ['mediaElement'],\n setup: seekToLiveEdgeSetup,\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DA,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;AAgD7B,SAAS,YAAY,cAA4C,MAA+C;CAC9G,OAAO,gBAAgB,OAAO,SAAS;AACzC;AAEA,SAAS,oBAAoB,EAC3B,OACA,SACA,UAY+D;CAC/D,MAAM,qBAAqB,eAAe,YAAY,QAAQ,aAAa,IAAI,GAAG,YAAY;EAAE;EAAO;CAAO,CAAC,CAAC,CAAC;CAEjH,OAAO,qBAA6C;EAClD,SAAS;EACT,eAAe,mBAAmB,IAAI;EACtC,QAAQ;GACN,UAAU,CAAC;GAEX,MAAM;IAKJ,aAAa;KAEX,MAAM,eAAe,QAAQ,aAAa,IAAI;KAC9C,MAAM,EAAE,kBAAkB,YAAY;MAAE;MAAO;KAAO,CAAC;KAGvD,IAAI,aAAa,eAAe,eAAe;KAC/C,MAAM,cAAc,IAAI,aAAa;IACvC;IAOA,eAAe;KAOb,MAAM,EAAE,OAAO,aAAa,kBAAkB,YAAY;MAAE;MAAO;KAAO,CAAC;KAC3E,MAAM,eAAe,KAAK,QAAQ,YAAY;KAC9C,MAAM,mBAAmB;MAUvB,IAAI,aAAa,QAAQ;MACzB,IAAI,aAAa,cAAc,cAAc,sBAC3C,aAAa,cAAc;KAE/B;KACA,WAAW;KAIX,OAAO,OAAO,cAAc,QAAQ,UAAU;IAChD;GACF;EACF;CACF,CAAC;AACH;;;;;;;AAQA,MAAa,iBAOT;CACF,WAAW,CAAC,gBAAgB,eAAe;CAC3C,aAAa,CAAC,cAAc;CAC5B,OAAO;AACT"}
@@ -0,0 +1,26 @@
1
+ import { effect } from "../../../core/signals/effect.js";
2
+ import { liveWindowFromState } from "../../primitives/live-window.js";
3
+ //#region src/playback/behaviors/dom/sync-live-seekable-range.ts
4
+ function syncLiveSeekableRangeSetup({ state, context }) {
5
+ return effect(() => {
6
+ const mediaSource = context.mediaSource.get();
7
+ const liveWindow = liveWindowFromState(state);
8
+ if (!mediaSource || !liveWindow) return;
9
+ mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end);
10
+ });
11
+ }
12
+ /**
13
+ * Manual `Behavior<>` literal (like `seekToLiveEdge`): declares only
14
+ * `presentation` in stateKeys while reading `selectedVideoTrackId` /
15
+ * `selectedAudioTrackId` defensively (contributed by the switch* behaviors), so
16
+ * it composes without a stateKeys/type conflict.
17
+ */
18
+ const syncLiveSeekableRange = {
19
+ stateKeys: ["presentation"],
20
+ contextKeys: ["mediaSource"],
21
+ setup: syncLiveSeekableRangeSetup
22
+ };
23
+ //#endregion
24
+ export { syncLiveSeekableRange };
25
+
26
+ //# sourceMappingURL=sync-live-seekable-range.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-live-seekable-range.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/sync-live-seekable-range.ts"],"sourcesContent":["/**\n * Mirror the live window into the MediaSource's seekable range. On every\n * window update — **including while paused** (the seekable range must stay\n * current as the window slides, regardless of play state) — declare\n * `setLiveSeekableRange(start, end)` so the browser's `HTMLMediaElement.seekable`\n * reflects the live window (without it, `seekable` is empty under\n * `duration === Infinity`).\n *\n * The live window comes from `liveWindowFromState` (the shared derivation —\n * the intersection over the selected A/V tracks' windows); inert when it\n * returns `null` (VoD / ended live). Composed *before* `seekToLiveEdge`\n * so the range is declared before that behavior seeks the playhead into it (a\n * seek outside `seekable` is clamped).\n *\n * Duration is owned solely by `updateMediaSourceDuration`; this behavior only\n * declares the seekable range (`setLiveSeekableRange` requires only\n * `readyState === 'open'` per the W3C MSE spec, not a set `duration`).\n *\n * No `clearLiveSeekableRange()` on termination, by design: the MSE spec consults\n * the live seekable range *only* while `duration === Infinity`. When a live\n * stream ends, `endOfStream()` sets a finite duration and the UA derives\n * `seekable` from buffered + duration, ignoring the live range — so clearing is\n * unnecessary. Clearing on the `ENDLIST`→finite-`Track.duration` transition\n * (when the window goes `null`) would also be premature: `duration` is still\n * `Infinity` until `endOfStream`, so clearing would shrink `seekable` to\n * buffered-only while the stream is still effectively live.\n */\nimport type { Behavior } from '../../../core/composition/create-composition';\nimport { effect } from '../../../core/signals/effect';\nimport type { ReadonlySignal } from '../../../core/signals/primitives';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\nimport { liveWindowFromState } from '../../primitives/live-window';\n\nexport interface SyncLiveSeekableRangeState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\nexport interface SyncLiveSeekableRangeContext {\n mediaSource?: MediaSource;\n}\n\nfunction syncLiveSeekableRangeSetup({\n state,\n context,\n}: {\n state: {\n presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']>;\n selectedVideoTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedVideoTrackId']>;\n selectedAudioTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedAudioTrackId']>;\n };\n context: {\n mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']>;\n };\n}): () => void {\n return effect(() => {\n const mediaSource = context.mediaSource.get();\n const liveWindow = liveWindowFromState(state);\n if (!mediaSource || !liveWindow) return;\n\n // Re-declared as the window slides so seekable tracks the live window\n // (the full DVR range remains seekable; seek-to-live-edge starts near the edge).\n // No readyState check, no try/catch: `setLiveSeekableRange` throws only on a\n // non-'open' readyState or an invalid range, and neither can occur here —\n // `setupMediaSource` publishes `context.mediaSource` only while open, and a\n // non-null live window means the timeline-bearing track is still `Infinity`\n // (so `endOfStream` hasn't ended the MS); `liveWindowFromState` guarantees\n // 0 ≤ start < end (the reference type's window starts ≥ 0, and the A/V\n // intersection `max`-clamps a non-reference track's negative early start).\n mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end);\n });\n}\n\n/**\n * Manual `Behavior<>` literal (like `seekToLiveEdge`): declares only\n * `presentation` in stateKeys while reading `selectedVideoTrackId` /\n * `selectedAudioTrackId` defensively (contributed by the switch* behaviors), so\n * it composes without a stateKeys/type conflict.\n */\nexport const syncLiveSeekableRange: Behavior<\n { presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']> },\n { mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']> },\n object\n> = {\n stateKeys: ['presentation'],\n contextKeys: ['mediaSource'],\n setup: syncLiveSeekableRangeSetup,\n};\n"],"mappings":";;;AA2CA,SAAS,2BAA2B,EAClC,OACA,WAUa;CACb,OAAO,aAAa;EAClB,MAAM,cAAc,QAAQ,YAAY,IAAI;EAC5C,MAAM,aAAa,oBAAoB,KAAK;EAC5C,IAAI,CAAC,eAAe,CAAC,YAAY;EAWjC,YAAY,qBAAqB,WAAW,OAAO,WAAW,GAAG;CACnE,CAAC;AACH;;;;;;;AAQA,MAAa,wBAIT;CACF,WAAW,CAAC,cAAc;CAC1B,aAAa,CAAC,aAAa;CAC3B,OAAO;AACT"}
@@ -8,13 +8,23 @@ import { getMaxBufferedEnd, shouldUpdateDuration, waitForSourceBuffersReady } fr
8
8
  * **Propagate `presentation.duration` to `mediaSource.duration` — exactly
9
9
  * once per MediaSource.**
10
10
  *
11
- * When the presentation has a valid positive duration (including `Infinity`
12
- * for live) and a MediaSource is in context, writes the value through to
13
- * `mediaSource.duration` on initial setup once, while `mediaSource.duration`
14
- * is still `NaN`. Once any non-NaN value is present (set by us, or by
15
- * `endOfStream` from the buffered end), the behavior leaves the property
16
- * alone; re-syncing a drift against `presentation.duration` would race with
17
- * concurrent `appendBuffer()` calls.
11
+ * Two paths, by whether the presentation is live:
12
+ *
13
+ * - **Live** (`presentation.duration === Infinity`): write `Infinity` once the
14
+ * MediaSource is open and no SourceBuffer is mid-append. No buffered clamp is
15
+ * needed (`Infinity` any range) and unlike the finite case — it needn't
16
+ * precede the first append: `Infinity` overrides whatever finite live-edge
17
+ * value an append may have pinned (a finite duration would otherwise stall
18
+ * the stream once the window slides past it). The wait-for-idle is required
19
+ * because the MSE spec forbids setting `duration` while a buffer is
20
+ * `updating`; both waits resolve immediately when already open / idle.
21
+ *
22
+ * - **VoD** (finite): the value is written once, after `mediaSource` is open and
23
+ * all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE
24
+ * spec). Written only while `mediaSource.duration` is still `NaN`; once any
25
+ * non-NaN value is present (us on a prior entry, or `endOfStream` from the
26
+ * buffered end), the property is left alone — re-syncing would race
27
+ * concurrent `appendBuffer()` calls.
18
28
  *
19
29
  * The entry resolves three async preconditions in order before writing:
20
30
  *
@@ -61,6 +71,18 @@ function updateMediaSourceDurationSetup({ state, context }) {
61
71
  "duration-writable": { entry: () => {
62
72
  const presentation = state.presentation.get();
63
73
  const mediaSource = context.mediaSource.get();
74
+ if (presentation.duration === Number.POSITIVE_INFINITY) {
75
+ if (mediaSource.duration === Number.POSITIVE_INFINITY) return;
76
+ const controller = new AbortController();
77
+ (async () => {
78
+ await waitForMediaSourceOpen(mediaSource, controller.signal);
79
+ if (controller.signal.aborted || mediaSource.readyState !== "open") return;
80
+ await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);
81
+ if (controller.signal.aborted || mediaSource.readyState !== "open") return;
82
+ if (mediaSource.duration !== Number.POSITIVE_INFINITY) mediaSource.duration = Number.POSITIVE_INFINITY;
83
+ })();
84
+ return () => controller.abort();
85
+ }
64
86
  if (!Number.isNaN(mediaSource.duration)) return;
65
87
  const controller = new AbortController();
66
88
  const writeWhenReady = async () => {
@@ -1 +1 @@
1
- {"version":3,"file":"update-mediasource-duration.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/update-mediasource-duration.ts"],"sourcesContent":["/**\n * **Propagate `presentation.duration` to `mediaSource.duration` — exactly\n * once per MediaSource.**\n *\n * When the presentation has a valid positive duration (including `Infinity`\n * for live) and a MediaSource is in context, writes the value through to\n * `mediaSource.duration` on initial setup — once, while `mediaSource.duration`\n * is still `NaN`. Once any non-NaN value is present (set by us, or by\n * `endOfStream` from the buffered end), the behavior leaves the property\n * alone; re-syncing a drift against `presentation.duration` would race with\n * concurrent `appendBuffer()` calls.\n *\n * The entry resolves three async preconditions in order before writing:\n *\n * 1. **MediaSource open** — `waitForMediaSourceOpen` defers until the first\n * `sourceopen` event (or any readyState transition, since `'ended'` /\n * `'closed'` mean we've missed the window and should bail).\n * 2. **All SourceBuffers idle** — `waitForSourceBuffersReady` defers per\n * the MSE-spec rule that `duration` cannot be set while any buffer has\n * `updating === true`.\n * 3. **Buffered-range clamp** — `getMaxBufferedEnd` ensures the written\n * duration is at least the highest buffered end (MSE spec disallows\n * a smaller `duration` than any buffered range).\n *\n * The buffer-set helpers operate across `mediaSource.sourceBuffers` (the\n * canonical aggregate), so the behavior composes uniformly across\n * audio-only, video-only, and mixed configurations.\n *\n * Single-positive-state reactor (`'preconditions-unmet'` ↔ `'duration-writable'`):\n * state derivation is purely signal-driven (presentation validity +\n * mediaSource existence). MediaSource lifecycle state (`readyState`) and\n * the `duration` itself are non-signal DOM properties resolved inside the\n * entry's async sequence. The state-exit cleanup aborts the in-flight\n * wait, so source resets and behavior destroy structurally cancel the\n * pending write. A post-await re-check of `mediaSource.readyState ===\n * 'open'` covers the narrow race where `endOfStream()` synchronously\n * transitions readyState to `'ended'` between our `waitForMediaSourceOpen`\n * resolution and the `mediaSource.duration` write.\n *\n * Downstream of `calculatePresentationDuration` (which writes\n * `presentation.duration`); concurrent with `endOfStream` (which may later\n * write `mediaSource.duration` — the \"exactly once\" contract keeps us out\n * of that path).\n */\nimport { defineBehavior } from '../../../core/composition/create-composition';\nimport type { Reactor } from '../../../core/reactors/create-machine-reactor';\nimport { createMachineReactor } from '../../../core/reactors/create-machine-reactor';\nimport { computed, type ReadonlySignal } from '../../../core/signals/primitives';\nimport { getMaxBufferedEnd, shouldUpdateDuration, waitForSourceBuffersReady } from '../../../media/dom/mse/duration';\nimport { waitForMediaSourceOpen } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\n\ntype DurationFsmState = 'preconditions-unmet' | 'duration-writable';\n\nfunction deriveState(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined\n): DurationFsmState {\n return shouldUpdateDuration(presentation, mediaSource) ? 'duration-writable' : 'preconditions-unmet';\n}\n\nfunction updateMediaSourceDurationSetup({\n state,\n context,\n}: {\n state: { presentation: ReadonlySignal<MaybeResolvedPresentation | undefined> };\n context: { mediaSource: ReadonlySignal<MediaSource | undefined> };\n}): Reactor<DurationFsmState | 'destroying' | 'destroyed'> {\n // Purely signal-driven derivation: only `presentation` validity and\n // `mediaSource` existence. MediaSource lifecycle (`readyState`),\n // its attached `sourceBuffers`, and `mediaSource.duration` itself are\n // non-signal DOM properties consumed inside the entry — none of them\n // sneak into `computed(...)`.\n const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaSource.get()));\n\n return createMachineReactor<DurationFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'duration-writable': {\n // entry body is auto-untracked. The state machine handles source\n // resets via deriveState; this entry resolves the MediaSource\n // lifecycle preconditions in sequence and binds its cleanup\n // (abort) to state exit + destroy.\n entry: () => {\n const presentation = state.presentation.get()!;\n const mediaSource = context.mediaSource.get()!;\n\n // Idempotency: someone (us on a prior entry, or concurrent\n // `endOfStream`) has already written. No-op and leave the state\n // alone — the next source-reset transition will produce a fresh\n // entry against the new MediaSource (whose `duration` starts at\n // `NaN` again).\n if (!Number.isNaN(mediaSource.duration)) return;\n\n const controller = new AbortController();\n\n const writeWhenReady = async () => {\n await waitForMediaSourceOpen(mediaSource, controller.signal);\n if (controller.signal.aborted) return;\n // `waitForMediaSourceOpen` resolves on any readyState transition\n // out of 'closed'; if we landed in 'ended' / 'closed' instead of\n // 'open', the write window is gone.\n if (mediaSource.readyState !== 'open') return;\n\n await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);\n if (controller.signal.aborted) return;\n\n // Narrow race: `endOfStream()` synchronously transitions\n // readyState to 'ended' AND writes `mediaSource.duration`\n // between our `waitForMediaSourceOpen` resolution and here.\n // Re-read the DOM property to catch this window.\n if (mediaSource.readyState !== 'open') return;\n\n // MSE spec: duration cannot be less than any buffered range.\n // Re-read sourceBuffers after the await so any buffer added\n // during the wait is included in the clamp.\n const maxBufferedEnd = getMaxBufferedEnd(mediaSource.sourceBuffers);\n const duration = maxBufferedEnd > presentation.duration! ? maxBufferedEnd : presentation.duration!;\n mediaSource.duration = duration;\n };\n\n writeWhenReady();\n\n return () => controller.abort();\n },\n },\n },\n });\n}\n\nexport const updateMediaSourceDuration = defineBehavior({\n stateKeys: ['presentation'],\n contextKeys: ['mediaSource'],\n setup: updateMediaSourceDurationSetup,\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDA,SAAS,YACP,cACA,aACkB;CAClB,OAAO,qBAAqB,cAAc,WAAW,IAAI,sBAAsB;AACjF;AAEA,SAAS,+BAA+B,EACtC,OACA,WAIyD;CAMzD,MAAM,qBAAqB,eAAe,YAAY,MAAM,aAAa,IAAI,GAAG,QAAQ,YAAY,IAAI,CAAC,CAAC;CAE1G,OAAO,qBAAuC;EAC5C,SAAS;EACT,eAAe,mBAAmB,IAAI;EACtC,QAAQ;GACN,uBAAuB,CAAC;GAExB,qBAAqB,EAKnB,aAAa;IACX,MAAM,eAAe,MAAM,aAAa,IAAI;IAC5C,MAAM,cAAc,QAAQ,YAAY,IAAI;IAO5C,IAAI,CAAC,OAAO,MAAM,YAAY,QAAQ,GAAG;IAEzC,MAAM,aAAa,IAAI,gBAAgB;IAEvC,MAAM,iBAAiB,YAAY;KACjC,MAAM,uBAAuB,aAAa,WAAW,MAAM;KAC3D,IAAI,WAAW,OAAO,SAAS;KAI/B,IAAI,YAAY,eAAe,QAAQ;KAEvC,MAAM,0BAA0B,YAAY,eAAe,WAAW,MAAM;KAC5E,IAAI,WAAW,OAAO,SAAS;KAM/B,IAAI,YAAY,eAAe,QAAQ;KAKvC,MAAM,iBAAiB,kBAAkB,YAAY,aAAa;KAClE,MAAM,WAAW,iBAAiB,aAAa,WAAY,iBAAiB,aAAa;KACzF,YAAY,WAAW;IACzB;IAEA,eAAe;IAEf,aAAa,WAAW,MAAM;GAChC,EACF;EACF;CACF,CAAC;AACH;AAEA,MAAa,4BAA4B,eAAe;CACtD,WAAW,CAAC,cAAc;CAC1B,aAAa,CAAC,aAAa;CAC3B,OAAO;AACT,CAAC"}
1
+ {"version":3,"file":"update-mediasource-duration.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/update-mediasource-duration.ts"],"sourcesContent":["/**\n * **Propagate `presentation.duration` to `mediaSource.duration` — exactly\n * once per MediaSource.**\n *\n * Two paths, by whether the presentation is live:\n *\n * - **Live** (`presentation.duration === Infinity`): write `Infinity` once the\n * MediaSource is open and no SourceBuffer is mid-append. No buffered clamp is\n * needed (`Infinity` ≥ any range) and — unlike the finite case — it needn't\n * precede the first append: `Infinity` overrides whatever finite live-edge\n * value an append may have pinned (a finite duration would otherwise stall\n * the stream once the window slides past it). The wait-for-idle is required\n * because the MSE spec forbids setting `duration` while a buffer is\n * `updating`; both waits resolve immediately when already open / idle.\n *\n * - **VoD** (finite): the value is written once, after `mediaSource` is open and\n * all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE\n * spec). Written only while `mediaSource.duration` is still `NaN`; once any\n * non-NaN value is present (us on a prior entry, or `endOfStream` from the\n * buffered end), the property is left alone — re-syncing would race\n * concurrent `appendBuffer()` calls.\n *\n * The entry resolves three async preconditions in order before writing:\n *\n * 1. **MediaSource open** — `waitForMediaSourceOpen` defers until the first\n * `sourceopen` event (or any readyState transition, since `'ended'` /\n * `'closed'` mean we've missed the window and should bail).\n * 2. **All SourceBuffers idle** — `waitForSourceBuffersReady` defers per\n * the MSE-spec rule that `duration` cannot be set while any buffer has\n * `updating === true`.\n * 3. **Buffered-range clamp** — `getMaxBufferedEnd` ensures the written\n * duration is at least the highest buffered end (MSE spec disallows\n * a smaller `duration` than any buffered range).\n *\n * The buffer-set helpers operate across `mediaSource.sourceBuffers` (the\n * canonical aggregate), so the behavior composes uniformly across\n * audio-only, video-only, and mixed configurations.\n *\n * Single-positive-state reactor (`'preconditions-unmet'` ↔ `'duration-writable'`):\n * state derivation is purely signal-driven (presentation validity +\n * mediaSource existence). MediaSource lifecycle state (`readyState`) and\n * the `duration` itself are non-signal DOM properties resolved inside the\n * entry's async sequence. The state-exit cleanup aborts the in-flight\n * wait, so source resets and behavior destroy structurally cancel the\n * pending write. A post-await re-check of `mediaSource.readyState ===\n * 'open'` covers the narrow race where `endOfStream()` synchronously\n * transitions readyState to `'ended'` between our `waitForMediaSourceOpen`\n * resolution and the `mediaSource.duration` write.\n *\n * Downstream of `calculatePresentationDuration` (which writes\n * `presentation.duration`); concurrent with `endOfStream` (which may later\n * write `mediaSource.duration` — the \"exactly once\" contract keeps us out\n * of that path).\n */\nimport { defineBehavior } from '../../../core/composition/create-composition';\nimport type { Reactor } from '../../../core/reactors/create-machine-reactor';\nimport { createMachineReactor } from '../../../core/reactors/create-machine-reactor';\nimport { computed, type ReadonlySignal } from '../../../core/signals/primitives';\nimport { getMaxBufferedEnd, shouldUpdateDuration, waitForSourceBuffersReady } from '../../../media/dom/mse/duration';\nimport { waitForMediaSourceOpen } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\n\ntype DurationFsmState = 'preconditions-unmet' | 'duration-writable';\n\nfunction deriveState(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined\n): DurationFsmState {\n return shouldUpdateDuration(presentation, mediaSource) ? 'duration-writable' : 'preconditions-unmet';\n}\n\nfunction updateMediaSourceDurationSetup({\n state,\n context,\n}: {\n state: { presentation: ReadonlySignal<MaybeResolvedPresentation | undefined> };\n context: { mediaSource: ReadonlySignal<MediaSource | undefined> };\n}): Reactor<DurationFsmState | 'destroying' | 'destroyed'> {\n // Purely signal-driven derivation: only `presentation` validity and\n // `mediaSource` existence. MediaSource lifecycle (`readyState`),\n // its attached `sourceBuffers`, and `mediaSource.duration` itself are\n // non-signal DOM properties consumed inside the entry — none of them\n // sneak into `computed(...)`.\n const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaSource.get()));\n\n return createMachineReactor<DurationFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'duration-writable': {\n // entry body is auto-untracked. The state machine handles source\n // resets via deriveState; this entry resolves the MediaSource\n // lifecycle preconditions in sequence and binds its cleanup\n // (abort) to state exit + destroy.\n entry: () => {\n const presentation = state.presentation.get()!;\n const mediaSource = context.mediaSource.get()!;\n\n // Live: write Infinity once the MediaSource is open and no buffer is\n // mid-append (the MSE spec forbids setting `duration` while a\n // SourceBuffer is `updating`, and racing an in-flight append throws).\n // No `NaN` guard, unlike the VoD path below — Infinity must override\n // whatever finite live-edge value an append may have pinned.\n if (presentation.duration === Number.POSITIVE_INFINITY) {\n if (mediaSource.duration === Number.POSITIVE_INFINITY) return;\n const controller = new AbortController();\n void (async () => {\n await waitForMediaSourceOpen(mediaSource, controller.signal);\n if (controller.signal.aborted || mediaSource.readyState !== 'open') return;\n await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);\n if (controller.signal.aborted || mediaSource.readyState !== 'open') return;\n if (mediaSource.duration !== Number.POSITIVE_INFINITY) {\n mediaSource.duration = Number.POSITIVE_INFINITY;\n }\n })();\n return () => controller.abort();\n }\n\n // VoD idempotency: someone (us on a prior entry, or concurrent\n // `endOfStream`) has already written. No-op and leave the state\n // alone — the next source-reset transition will produce a fresh\n // entry against the new MediaSource (whose `duration` starts at\n // `NaN` again).\n if (!Number.isNaN(mediaSource.duration)) return;\n\n const controller = new AbortController();\n\n const writeWhenReady = async () => {\n await waitForMediaSourceOpen(mediaSource, controller.signal);\n if (controller.signal.aborted) return;\n // `waitForMediaSourceOpen` resolves on any readyState transition\n // out of 'closed'; if we landed in 'ended' / 'closed' instead of\n // 'open', the write window is gone.\n if (mediaSource.readyState !== 'open') return;\n\n await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);\n if (controller.signal.aborted) return;\n\n // Narrow race: `endOfStream()` synchronously transitions\n // readyState to 'ended' AND writes `mediaSource.duration`\n // between our `waitForMediaSourceOpen` resolution and here.\n // Re-read the DOM property to catch this window.\n if (mediaSource.readyState !== 'open') return;\n\n // MSE spec: duration cannot be less than any buffered range.\n // Re-read sourceBuffers after the await so any buffer added\n // during the wait is included in the clamp.\n const maxBufferedEnd = getMaxBufferedEnd(mediaSource.sourceBuffers);\n const duration = maxBufferedEnd > presentation.duration! ? maxBufferedEnd : presentation.duration!;\n mediaSource.duration = duration;\n };\n\n writeWhenReady();\n\n return () => controller.abort();\n },\n },\n },\n });\n}\n\nexport const updateMediaSourceDuration = defineBehavior({\n stateKeys: ['presentation'],\n contextKeys: ['mediaSource'],\n setup: updateMediaSourceDurationSetup,\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgEA,SAAS,YACP,cACA,aACkB;CAClB,OAAO,qBAAqB,cAAc,WAAW,IAAI,sBAAsB;AACjF;AAEA,SAAS,+BAA+B,EACtC,OACA,WAIyD;CAMzD,MAAM,qBAAqB,eAAe,YAAY,MAAM,aAAa,IAAI,GAAG,QAAQ,YAAY,IAAI,CAAC,CAAC;CAE1G,OAAO,qBAAuC;EAC5C,SAAS;EACT,eAAe,mBAAmB,IAAI;EACtC,QAAQ;GACN,uBAAuB,CAAC;GAExB,qBAAqB,EAKnB,aAAa;IACX,MAAM,eAAe,MAAM,aAAa,IAAI;IAC5C,MAAM,cAAc,QAAQ,YAAY,IAAI;IAO5C,IAAI,aAAa,aAAa,OAAO,mBAAmB;KACtD,IAAI,YAAY,aAAa,OAAO,mBAAmB;KACvD,MAAM,aAAa,IAAI,gBAAgB;KACvC,CAAM,YAAY;MAChB,MAAM,uBAAuB,aAAa,WAAW,MAAM;MAC3D,IAAI,WAAW,OAAO,WAAW,YAAY,eAAe,QAAQ;MACpE,MAAM,0BAA0B,YAAY,eAAe,WAAW,MAAM;MAC5E,IAAI,WAAW,OAAO,WAAW,YAAY,eAAe,QAAQ;MACpE,IAAI,YAAY,aAAa,OAAO,mBAClC,YAAY,WAAW,OAAO;KAElC,EAAA,CAAG;KACH,aAAa,WAAW,MAAM;IAChC;IAOA,IAAI,CAAC,OAAO,MAAM,YAAY,QAAQ,GAAG;IAEzC,MAAM,aAAa,IAAI,gBAAgB;IAEvC,MAAM,iBAAiB,YAAY;KACjC,MAAM,uBAAuB,aAAa,WAAW,MAAM;KAC3D,IAAI,WAAW,OAAO,SAAS;KAI/B,IAAI,YAAY,eAAe,QAAQ;KAEvC,MAAM,0BAA0B,YAAY,eAAe,WAAW,MAAM;KAC5E,IAAI,WAAW,OAAO,SAAS;KAM/B,IAAI,YAAY,eAAe,QAAQ;KAKvC,MAAM,iBAAiB,kBAAkB,YAAY,aAAa;KAClE,MAAM,WAAW,iBAAiB,aAAa,WAAY,iBAAiB,aAAa;KACzF,YAAY,WAAW;IACzB;IAEA,eAAe;IAEf,aAAa,WAAW,MAAM;GAChC,EACF;EACF;CACF,CAAC;AACH;AAEA,MAAa,4BAA4B,eAAe;CACtD,WAAW,CAAC,cAAc;CAC1B,aAAa,CAAC,aAAa;CAC3B,OAAO;AACT,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import { update } from "../../core/signals/primitives.js";
2
2
  import { createMachineReactor } from "../../core/reactors/create-machine-reactor.js";
3
- import { isResolvedPresentation } from "../../media/types/index.js";
3
+ import { isResolvedPresentation, isResolvedTrack } from "../../media/types/index.js";
4
4
  import { findTrackById } from "../../media/utils/tracks.js";
5
5
  //#region src/playback/behaviors/establish-start-media-time.ts
6
6
  /**
@@ -49,6 +49,11 @@ const deriveSharedMinStartMediaTime = (containerData, ctx) => {
49
49
  * the default: it flattens real A/V skew (see {@link deriveSharedMinStartMediaTime}).
50
50
  * Kept as an opt-in for compositions that know their A/V is aligned and want to skip
51
51
  * the shared-`min` barrier (each type stamps as soon as its own origin is discovered).
52
+ *
53
+ * Caution: {@link NEAR_ZERO_ORIGIN_THRESHOLD} applies independently per type here — if
54
+ * two types' origins straddle it, one snaps to `0` and the other doesn't, injecting up
55
+ * to a threshold's worth of A/V offset. Safe for the intended aligned-A/V opt-in; see
56
+ * `internal/design/spf/live-presentation-timeline-model.md` (collision 5).
52
57
  */
53
58
  const derivePerTypeStartMediaTime = (containerData) => {
54
59
  const out = {};
@@ -58,6 +63,66 @@ const derivePerTypeStartMediaTime = (containerData) => {
58
63
  }
59
64
  return out;
60
65
  };
66
+ /**
67
+ * The anchor-source track: the selected video track, falling back to audio
68
+ * (audio-only). Deterministic — never "first resolved", which is race-dependent
69
+ * under concurrent resolves.
70
+ */
71
+ function referenceTrackId(ctx) {
72
+ return ctx.selectedVideoTrackId ?? ctx.selectedAudioTrackId;
73
+ }
74
+ /**
75
+ * The live-anchor first-parse gate (the "parses align" step of the establishment
76
+ * order). Open for the reference track itself — its local-from-0 placement *is*
77
+ * the presentation timeline (presentation-0 = the join point). Every other track
78
+ * holds until the reference's first parse settles the anchor question: no PDT →
79
+ * local placement is already correct, proceed; PDT anchor → hold until the
80
+ * establishment reactor stamps it onto this track, so the parse takes the
81
+ * `placeOnAnchor` path. Establishment is sticky-once, so an unanchored first
82
+ * parse would be permanently misaligned — that's the race this gate closes.
83
+ *
84
+ * Pairs with the {@link establishStartMediaTime} reactor (the stamp that opens
85
+ * the anchored branch); wire both or neither.
86
+ */
87
+ const gateFirstParseOnAnchor = (presentation, ctx, trackId) => {
88
+ const referenceId = referenceTrackId(ctx);
89
+ if (referenceId === void 0 || referenceId === trackId) return true;
90
+ if (!isResolvedPresentation(presentation)) return false;
91
+ const reference = findTrackById(presentation, referenceId);
92
+ if (!reference) return true;
93
+ if (!isResolvedTrack(reference)) return false;
94
+ if (reference.startDate === void 0) return true;
95
+ return findTrackById(presentation, trackId)?.startDate !== void 0;
96
+ };
97
+ /**
98
+ * Stamp the frozen wall-clock anchor as `startDate` onto every track that lacks
99
+ * one (idempotent — same reference when nothing moved). Unresolved shells pick
100
+ * it up as the `placeOnAnchor` preset; covering *all* tracks in one pass means
101
+ * any track selected later — an ABR rung, another audio language, late captions
102
+ * — resolves already anchored. The reference track's own parser-computed
103
+ * `startDate` reads back as the anchor, so it's naturally left untouched.
104
+ */
105
+ function stampStartDates(presentation, anchor) {
106
+ let changed = false;
107
+ const selectionSets = presentation.selectionSets.map((selectionSet) => ({
108
+ ...selectionSet,
109
+ switchingSets: selectionSet.switchingSets.map((switchingSet) => ({
110
+ ...switchingSet,
111
+ tracks: switchingSet.tracks.map((track) => {
112
+ if (track.startDate !== void 0) return track;
113
+ changed = true;
114
+ return {
115
+ ...track,
116
+ startDate: anchor
117
+ };
118
+ })
119
+ }))
120
+ }));
121
+ return changed ? {
122
+ ...presentation,
123
+ selectionSets
124
+ } : presentation;
125
+ }
61
126
  /** Stamp the derived per-track `startMediaTime` onto the model (idempotent — same reference when nothing moved). */
62
127
  function stampTracks(presentation, startMediaTimes) {
63
128
  let changed = false;
@@ -102,12 +167,21 @@ function establishStartMediaTimeSetup({ state, config = {} }) {
102
167
  },
103
168
  states: {
104
169
  inactive: { entry: () => state.mediaContainerData.set(void 0) },
105
- monitoring: { effects: () => {
170
+ monitoring: { effects: [() => {
171
+ const presentation = state.presentation.get();
172
+ if (!isResolvedPresentation(presentation)) return;
173
+ const referenceId = referenceTrackId(selectionContext());
174
+ const reference = referenceId === void 0 ? void 0 : findTrackById(presentation, referenceId);
175
+ if (!reference || !isResolvedTrack(reference)) return;
176
+ const anchor = reference.startDate;
177
+ if (anchor === void 0) return;
178
+ update(state.presentation, (current) => stampStartDates(current, anchor));
179
+ }, () => {
106
180
  const containerData = state.mediaContainerData.get();
107
181
  if (!containerData) return;
108
182
  const startMediaTimes = derive(containerData, selectionContext());
109
183
  update(state.presentation, (current) => stampTracks(current, startMediaTimes));
110
- } },
184
+ }] },
111
185
  established: {}
112
186
  }
113
187
  });
@@ -118,6 +192,6 @@ const establishStartMediaTime = {
118
192
  setup: establishStartMediaTimeSetup
119
193
  };
120
194
  //#endregion
121
- export { derivePerTypeStartMediaTime, deriveSharedMinStartMediaTime, establishStartMediaTime };
195
+ export { derivePerTypeStartMediaTime, deriveSharedMinStartMediaTime, establishStartMediaTime, gateFirstParseOnAnchor };
122
196
 
123
197
  //# sourceMappingURL=establish-start-media-time.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"establish-start-media-time.js","names":[],"sources":["../../../../src/playback/behaviors/establish-start-media-time.ts"],"sourcesContent":["/**\n * Establish each track's `startMediaTime` (its media-timeline origin) once per\n * source, so a non-zero-PTS source relocates onto a 0-based presentation timeline.\n * The VOD sibling of `anchor-presentation-timeline`: both are per-source\n * establishment units that write a coordinate base value onto every track (the\n * anchor writes `startDate`/`startTime` from wall clock; this writes\n * `startMediaTime` from the container's decode-time origin).\n *\n * This is the **DOM-free reactor half**: it owns the transient `mediaContainerData`\n * slot lifecycle (`inactive` clears it per source), runs the injected\n * {@link DeriveStartMediaTime} seam over it in `monitoring`, and stamps the settled\n * `startMediaTime` onto the model — the coordinate *consume* — until `established`.\n * The byte-level discover/stamp steps that fill the slot are a separate,\n * config `messagePipelines` array (`primitives/relocation-pipelines`);\n * the two coordinate only through the shared `state.mediaContainerData` slot, never\n * by import. See `internal/design/spf/presentation-timeline-model.md`.\n */\nimport type { Behavior } from '../../core/composition/create-composition';\nimport { createMachineReactor, type Reactor } from '../../core/reactors/create-machine-reactor';\nimport { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';\nimport {\n isResolvedPresentation,\n type MaybeResolvedPresentation,\n type MediaContainerData,\n type Presentation,\n} from '../../media/types';\nimport { findTrackById } from '../../media/utils/tracks';\nimport type { DeriveStartMediaTime, DeriveStartMediaTimeContext } from '../primitives/derive-start-media-time';\n\nexport type { DeriveStartMediaTime, DeriveStartMediaTimeContext };\n\n// ============================================================================\n// STATE / CONFIG\n// ============================================================================\n\nexport interface EstablishStartMediaTimeState {\n presentation?: MaybeResolvedPresentation;\n /**\n * Transient origin-establishment data, keyed by **track type** (`'video'` /\n * `'audio'`) — per spec one init+media pair per type suffices, and ABR rungs of\n * a type share the origin. Filled by the discover steps across appends, reset\n * per source. Never the model — the churn stays here; only the settled\n * `startMediaTime` reaches `Track`.\n */\n mediaContainerData?: Record<string, MediaContainerData>;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * A single type's own media-timeline origin:\n * `baseMediaDecodeTime/timescale − segmentStartTime` (the `segmentStartTime` term\n * makes it the stream origin even when the first loaded segment isn't the 0th).\n * `undefined` until timescale + baseMediaDecodeTime + segmentStartTime are all present.\n */\nfunction ownOrigin(data: MediaContainerData | undefined): number | undefined {\n const { timescale, baseMediaDecodeTime, segmentStartTime } = data ?? {};\n return timescale != null && baseMediaDecodeTime != null && segmentStartTime != null\n ? baseMediaDecodeTime / timescale - segmentStartTime\n : undefined;\n}\n\n/**\n * Origins below this magnitude (seconds) are treated as `0` — the derive returns `0`, so\n * the presentation is left on its native (~0-based) timeline and no `timestampOffset` is\n * set (the loader stamp no-ops on a derived `0`). Ordinary VOD carries a small nonzero\n * encode origin (audio priming, first-frame CTS, edit lists); relocating by a sub-second\n * amount is pointless, and setting `timestampOffset` at all can ripple edge segments.\n * Relocation targets streams with an intentional large origin (instant clips, bipbop @10s).\n *\n * Absolute basis, not proportional: the DTS-below-zero / ripple risk scales with the\n * origin's absolute size, not with the presentation's duration. Also snaps negatives to\n * `0` — a negative origin would relocate *forward*, which is never the intent.\n */\nexport const NEAR_ZERO_ORIGIN_THRESHOLD = 1;\n\n/** Snap a below-threshold (incl. negative) origin to `0` so it isn't relocated. */\nfunction thresholdOrigin(origin: number): number {\n return origin < NEAR_ZERO_ORIGIN_THRESHOLD ? 0 : origin;\n}\n\n/**\n * The **default** — relocate the whole presentation by one shared origin: the `min`\n * across the *selected* A/V tracks' own origins, denormalized onto every type. This\n * single reduce subsumes the \"per-type\" and \"shared\" tiers:\n * - **aligned A/V** — `min` equals each origin (they're equal), so it matches per-type;\n * - **skewed A/V** (e.g. Apple's 44ms audio-lead) — `min` keeps every track's earliest\n * DTS ≥ 0 (relocating by ≤ each own origin never drives one negative) *and* preserves\n * the real skew (per-type would flatten it, desyncing A/V);\n * - **single type / muxed** — `min` of the one origin is that origin.\n *\n * Returns `undefined` for every type until all *selected* types have a complete origin\n * (the shared-`min` barrier). Which types must contribute is read from `ctx` (the\n * selected v/a ids); with no selection context it coordinates across whatever types\n * have data. A shared origin below {@link NEAR_ZERO_ORIGIN_THRESHOLD} is returned as `0`\n * (native — ordinary ~0-PTS VOD isn't relocated).\n */\nexport const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerData, ctx) => {\n const contributingTypes: string[] = [];\n if (ctx.selectedVideoTrackId != null) contributingTypes.push('video');\n if (ctx.selectedAudioTrackId != null) contributingTypes.push('audio');\n const types = contributingTypes.length > 0 ? contributingTypes : Object.keys(containerData);\n\n const origins = types.map((type) => ownOrigin(containerData[type]));\n // Barrier: not ready until every contributing type has a complete origin.\n if (origins.length === 0 || origins.some((origin) => origin === undefined)) return {};\n\n const shared = thresholdOrigin(Math.min(...(origins as number[])));\n const out: Record<string, number | undefined> = {};\n for (const type of Object.keys(containerData)) out[type] = shared;\n return out;\n};\n\n/**\n * Coordination-axis *off* — each type relocates by its own origin, independently. Not\n * the default: it flattens real A/V skew (see {@link deriveSharedMinStartMediaTime}).\n * Kept as an opt-in for compositions that know their A/V is aligned and want to skip\n * the shared-`min` barrier (each type stamps as soon as its own origin is discovered).\n */\nexport const derivePerTypeStartMediaTime: DeriveStartMediaTime = (containerData) => {\n const out: Record<string, number | undefined> = {};\n for (const [type, data] of Object.entries(containerData)) {\n const origin = ownOrigin(data);\n out[type] = origin === undefined ? undefined : thresholdOrigin(origin);\n }\n return out;\n};\n\nexport interface EstablishStartMediaTimeConfig {\n /** The reduce seam (coordination knob). Defaults to {@link deriveSharedMinStartMediaTime}. */\n deriveStartMediaTime?: DeriveStartMediaTime;\n}\n\n// ============================================================================\n// REACTOR (owns the transient slot; derives + consumes onto the model)\n// ============================================================================\n\n/** Stamp the derived per-track `startMediaTime` onto the model (idempotent — same reference when nothing moved). */\nfunction stampTracks(presentation: Presentation, startMediaTimes: Record<string, number | undefined>): Presentation {\n let changed = false;\n const selectionSets = presentation.selectionSets.map((selectionSet) => ({\n ...selectionSet,\n switchingSets: selectionSet.switchingSets.map((switchingSet) => ({\n ...switchingSet,\n tracks: switchingSet.tracks.map((track) => {\n const startMediaTime = startMediaTimes[track.type];\n if (startMediaTime === undefined || track.startMediaTime === startMediaTime) return track;\n changed = true;\n return { ...track, startMediaTime };\n }),\n })),\n }));\n return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation;\n}\n\ntype EstablishFsmState = 'inactive' | 'monitoring' | 'established';\n\ninterface EstablishStartMediaTimeDeps {\n state: {\n presentation: Signal<EstablishStartMediaTimeState['presentation']>;\n mediaContainerData: Signal<EstablishStartMediaTimeState['mediaContainerData']>;\n // Optional so the one behavior composes across video-only / audio-only / both,\n // like other cross-track-type behaviors (present at runtime iff a sibling owns it).\n selectedVideoTrackId?: ReadonlySignal<EstablishStartMediaTimeState['selectedVideoTrackId']>;\n selectedAudioTrackId?: ReadonlySignal<EstablishStartMediaTimeState['selectedAudioTrackId']>;\n };\n config?: EstablishStartMediaTimeConfig;\n}\n\nfunction establishStartMediaTimeSetup({\n state,\n config = {},\n}: EstablishStartMediaTimeDeps): Reactor<EstablishFsmState | 'destroying' | 'destroyed'> {\n const derive = config.deriveStartMediaTime ?? deriveSharedMinStartMediaTime;\n\n const selectionContext = (): DeriveStartMediaTimeContext => ({\n selectedVideoTrackId: state.selectedVideoTrackId?.get(),\n selectedAudioTrackId: state.selectedAudioTrackId?.get(),\n });\n\n /** Established once the selected A/V tracks (whichever exist) carry `startMediaTime`. */\n const established = (): boolean => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return false;\n const ids = [state.selectedVideoTrackId?.get(), state.selectedAudioTrackId?.get()].filter(\n (id): id is string => id !== undefined\n );\n return ids.length > 0 && ids.every((id) => findTrackById(presentation, id)?.startMediaTime !== undefined);\n };\n\n return createMachineReactor<EstablishFsmState>({\n initial: 'inactive',\n monitor: () => {\n if (!isResolvedPresentation(state.presentation.get())) return 'inactive';\n return established() ? 'established' : 'monitoring';\n },\n states: {\n // Fresh per source: clear the transient slot so a new source re-discovers.\n inactive: { entry: () => state.mediaContainerData.set(undefined) },\n // Derive: reduce the accumulating container data into per-track startMediaTime\n // and stamp it onto the model. Re-runs as discover fills the slot; the update\n // reads presentation untracked, so no self-loop. Disabled on entry to\n // `established` (establish-once, sticky per source).\n monitoring: {\n effects: () => {\n const containerData = state.mediaContainerData.get();\n if (!containerData) return;\n const startMediaTimes = derive(containerData, selectionContext());\n // `current` is always resolved here — the monitor gates `monitoring` on a\n // resolved presentation and transitions before effects re-run — so we cast\n // to `Presentation` rather than re-narrowing.\n update(state.presentation as Signal<MaybeResolvedPresentation>, (current) =>\n stampTracks(current as Presentation, startMediaTimes)\n );\n },\n },\n established: {},\n },\n });\n}\n\nexport const establishStartMediaTime: Behavior<\n {\n presentation: Signal<EstablishStartMediaTimeState['presentation']>;\n mediaContainerData: Signal<EstablishStartMediaTimeState['mediaContainerData']>;\n },\n Record<never, never>,\n EstablishStartMediaTimeConfig\n> = {\n stateKeys: ['presentation', 'mediaContainerData'],\n contextKeys: [],\n setup: establishStartMediaTimeSetup,\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"establish-start-media-time.js","names":[],"sources":["../../../../src/playback/behaviors/establish-start-media-time.ts"],"sourcesContent":["/**\n * Establish the presentation's coordinate origin — the `startTime` /\n * `startMediaTime` / `startDate` triple — once per source, so every track lands\n * on one 0-based presentation timeline. One establishment unit with an internal\n * order: the wall-clock **anchor** settles (live PDT sources), gated parses\n * align to it, then `startMediaTime` latches — see\n * `internal/design/spf/live-presentation-timeline-model.md`.\n *\n * The two coordinate stamps it owns:\n * - **`startDate` (wall-clock anchor).** The designated *reference track*\n * (selected video, else audio) resolves first; its parser-computed\n * `startDate` — PDT at presentation-0 — is frozen as the anchor and stamped\n * onto every other track that lacks one, so their first parses place via\n * `placeOnAnchor` (the pre-applied-anchor path in `parse-media-playlist`)\n * and later-selected renditions resolve already aligned. Ordering is\n * enforced by {@link gateFirstParseOnAnchor}, which `resolve-track` awaits\n * through its injected gate seam. Absent PDT (ordinary VOD), no anchor is\n * stamped and local-from-0 placement is already correct.\n * - **`startMediaTime` (decode origin).** Runs the injected\n * {@link DeriveStartMediaTime} seam over the transient `mediaContainerData`\n * slot (owned here; `inactive` clears it per source) and stamps the settled\n * value onto the model until `established`. The byte-level discover/stamp\n * steps that fill the slot are a separate, config `messagePipelines` array\n * (`primitives/relocation-pipelines`); the two coordinate only through the\n * shared `state.mediaContainerData` slot, never by import. See\n * `internal/design/spf/presentation-timeline-model.md`.\n *\n * DOM-free reactor; sticky per source (`inactive → monitoring → established`).\n */\nimport type { Behavior } from '../../core/composition/create-composition';\nimport { createMachineReactor, type Reactor } from '../../core/reactors/create-machine-reactor';\nimport { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';\nimport {\n isResolvedPresentation,\n isResolvedTrack,\n type MaybeResolvedPresentation,\n type MediaContainerData,\n type Presentation,\n} from '../../media/types';\nimport { findTrackById } from '../../media/utils/tracks';\nimport type { DeriveStartMediaTime, DeriveStartMediaTimeContext } from '../primitives/derive-start-media-time';\nimport type { GateFirstParse, GateFirstParseContext } from '../primitives/gate-first-parse';\n\nexport type { DeriveStartMediaTime, DeriveStartMediaTimeContext, GateFirstParse, GateFirstParseContext };\n\n// ============================================================================\n// STATE / CONFIG\n// ============================================================================\n\nexport interface EstablishStartMediaTimeState {\n presentation?: MaybeResolvedPresentation;\n /**\n * Transient origin-establishment data, keyed by **track type** (`'video'` /\n * `'audio'`) — per spec one init+media pair per type suffices, and ABR rungs of\n * a type share the origin. Filled by the discover steps across appends, reset\n * per source. Never the model — the churn stays here; only the settled\n * `startMediaTime` reaches `Track`.\n */\n mediaContainerData?: Record<string, MediaContainerData>;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * A single type's own media-timeline origin:\n * `baseMediaDecodeTime/timescale − segmentStartTime` (the `segmentStartTime` term\n * makes it the stream origin even when the first loaded segment isn't the 0th).\n * `undefined` until timescale + baseMediaDecodeTime + segmentStartTime are all present.\n */\nfunction ownOrigin(data: MediaContainerData | undefined): number | undefined {\n const { timescale, baseMediaDecodeTime, segmentStartTime } = data ?? {};\n return timescale != null && baseMediaDecodeTime != null && segmentStartTime != null\n ? baseMediaDecodeTime / timescale - segmentStartTime\n : undefined;\n}\n\n/**\n * Origins below this magnitude (seconds) are treated as `0` — the derive returns `0`, so\n * the presentation is left on its native (~0-based) timeline and no `timestampOffset` is\n * set (the loader stamp no-ops on a derived `0`). Ordinary VOD carries a small nonzero\n * encode origin (audio priming, first-frame CTS, edit lists); relocating by a sub-second\n * amount is pointless, and setting `timestampOffset` at all can ripple edge segments.\n * Relocation targets streams with an intentional large origin (instant clips, bipbop @10s).\n *\n * Absolute basis, not proportional: the DTS-below-zero / ripple risk scales with the\n * origin's absolute size, not with the presentation's duration. Also snaps negatives to\n * `0` — a negative origin would relocate *forward*, which is never the intent.\n */\nexport const NEAR_ZERO_ORIGIN_THRESHOLD = 1;\n\n/** Snap a below-threshold (incl. negative) origin to `0` so it isn't relocated. */\nfunction thresholdOrigin(origin: number): number {\n return origin < NEAR_ZERO_ORIGIN_THRESHOLD ? 0 : origin;\n}\n\n/**\n * The **default** — relocate the whole presentation by one shared origin: the `min`\n * across the *selected* A/V tracks' own origins, denormalized onto every type. This\n * single reduce subsumes the \"per-type\" and \"shared\" tiers:\n * - **aligned A/V** — `min` equals each origin (they're equal), so it matches per-type;\n * - **skewed A/V** (e.g. Apple's 44ms audio-lead) — `min` keeps every track's earliest\n * DTS ≥ 0 (relocating by ≤ each own origin never drives one negative) *and* preserves\n * the real skew (per-type would flatten it, desyncing A/V);\n * - **single type / muxed** — `min` of the one origin is that origin.\n *\n * Returns `undefined` for every type until all *selected* types have a complete origin\n * (the shared-`min` barrier). Which types must contribute is read from `ctx` (the\n * selected v/a ids); with no selection context it coordinates across whatever types\n * have data. A shared origin below {@link NEAR_ZERO_ORIGIN_THRESHOLD} is returned as `0`\n * (native — ordinary ~0-PTS VOD isn't relocated).\n */\nexport const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerData, ctx) => {\n const contributingTypes: string[] = [];\n if (ctx.selectedVideoTrackId != null) contributingTypes.push('video');\n if (ctx.selectedAudioTrackId != null) contributingTypes.push('audio');\n const types = contributingTypes.length > 0 ? contributingTypes : Object.keys(containerData);\n\n const origins = types.map((type) => ownOrigin(containerData[type]));\n // Barrier: not ready until every contributing type has a complete origin.\n if (origins.length === 0 || origins.some((origin) => origin === undefined)) return {};\n\n const shared = thresholdOrigin(Math.min(...(origins as number[])));\n const out: Record<string, number | undefined> = {};\n for (const type of Object.keys(containerData)) out[type] = shared;\n return out;\n};\n\n/**\n * Coordination-axis *off* — each type relocates by its own origin, independently. Not\n * the default: it flattens real A/V skew (see {@link deriveSharedMinStartMediaTime}).\n * Kept as an opt-in for compositions that know their A/V is aligned and want to skip\n * the shared-`min` barrier (each type stamps as soon as its own origin is discovered).\n *\n * Caution: {@link NEAR_ZERO_ORIGIN_THRESHOLD} applies independently per type here — if\n * two types' origins straddle it, one snaps to `0` and the other doesn't, injecting up\n * to a threshold's worth of A/V offset. Safe for the intended aligned-A/V opt-in; see\n * `internal/design/spf/live-presentation-timeline-model.md` (collision 5).\n */\nexport const derivePerTypeStartMediaTime: DeriveStartMediaTime = (containerData) => {\n const out: Record<string, number | undefined> = {};\n for (const [type, data] of Object.entries(containerData)) {\n const origin = ownOrigin(data);\n out[type] = origin === undefined ? undefined : thresholdOrigin(origin);\n }\n return out;\n};\n\n/**\n * The anchor-source track: the selected video track, falling back to audio\n * (audio-only). Deterministic — never \"first resolved\", which is race-dependent\n * under concurrent resolves.\n */\nfunction referenceTrackId(ctx: GateFirstParseContext): string | undefined {\n return ctx.selectedVideoTrackId ?? ctx.selectedAudioTrackId;\n}\n\n/**\n * The live-anchor first-parse gate (the \"parses align\" step of the establishment\n * order). Open for the reference track itself — its local-from-0 placement *is*\n * the presentation timeline (presentation-0 = the join point). Every other track\n * holds until the reference's first parse settles the anchor question: no PDT →\n * local placement is already correct, proceed; PDT anchor → hold until the\n * establishment reactor stamps it onto this track, so the parse takes the\n * `placeOnAnchor` path. Establishment is sticky-once, so an unanchored first\n * parse would be permanently misaligned — that's the race this gate closes.\n *\n * Pairs with the {@link establishStartMediaTime} reactor (the stamp that opens\n * the anchored branch); wire both or neither.\n */\nexport const gateFirstParseOnAnchor: GateFirstParse = (presentation, ctx, trackId) => {\n const referenceId = referenceTrackId(ctx);\n if (referenceId === undefined || referenceId === trackId) return true;\n if (!isResolvedPresentation(presentation)) return false;\n const reference = findTrackById(presentation, referenceId);\n // A dangling reference id can never anchor — don't deadlock on it.\n if (!reference) return true;\n if (!isResolvedTrack(reference)) return false;\n if (reference.startDate === undefined) return true;\n return findTrackById(presentation, trackId)?.startDate !== undefined;\n};\n\nexport interface EstablishStartMediaTimeConfig {\n /** The reduce seam (coordination knob). Defaults to {@link deriveSharedMinStartMediaTime}. */\n deriveStartMediaTime?: DeriveStartMediaTime;\n}\n\n// ============================================================================\n// REACTOR (owns the transient slot; derives + consumes onto the model)\n// ============================================================================\n\n/**\n * Stamp the frozen wall-clock anchor as `startDate` onto every track that lacks\n * one (idempotent — same reference when nothing moved). Unresolved shells pick\n * it up as the `placeOnAnchor` preset; covering *all* tracks in one pass means\n * any track selected later — an ABR rung, another audio language, late captions\n * — resolves already anchored. The reference track's own parser-computed\n * `startDate` reads back as the anchor, so it's naturally left untouched.\n */\nfunction stampStartDates(presentation: Presentation, anchor: number): Presentation {\n let changed = false;\n const selectionSets = presentation.selectionSets.map((selectionSet) => ({\n ...selectionSet,\n switchingSets: selectionSet.switchingSets.map((switchingSet) => ({\n ...switchingSet,\n tracks: switchingSet.tracks.map((track) => {\n if (track.startDate !== undefined) return track;\n changed = true;\n return { ...track, startDate: anchor };\n }),\n })),\n }));\n return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation;\n}\n\n/** Stamp the derived per-track `startMediaTime` onto the model (idempotent — same reference when nothing moved). */\nfunction stampTracks(presentation: Presentation, startMediaTimes: Record<string, number | undefined>): Presentation {\n let changed = false;\n const selectionSets = presentation.selectionSets.map((selectionSet) => ({\n ...selectionSet,\n switchingSets: selectionSet.switchingSets.map((switchingSet) => ({\n ...switchingSet,\n tracks: switchingSet.tracks.map((track) => {\n const startMediaTime = startMediaTimes[track.type];\n if (startMediaTime === undefined || track.startMediaTime === startMediaTime) return track;\n changed = true;\n return { ...track, startMediaTime };\n }),\n })),\n }));\n return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation;\n}\n\ntype EstablishFsmState = 'inactive' | 'monitoring' | 'established';\n\ninterface EstablishStartMediaTimeDeps {\n state: {\n presentation: Signal<EstablishStartMediaTimeState['presentation']>;\n mediaContainerData: Signal<EstablishStartMediaTimeState['mediaContainerData']>;\n // Optional so the one behavior composes across video-only / audio-only / both,\n // like other cross-track-type behaviors (present at runtime iff a sibling owns it).\n selectedVideoTrackId?: ReadonlySignal<EstablishStartMediaTimeState['selectedVideoTrackId']>;\n selectedAudioTrackId?: ReadonlySignal<EstablishStartMediaTimeState['selectedAudioTrackId']>;\n };\n config?: EstablishStartMediaTimeConfig;\n}\n\nfunction establishStartMediaTimeSetup({\n state,\n config = {},\n}: EstablishStartMediaTimeDeps): Reactor<EstablishFsmState | 'destroying' | 'destroyed'> {\n const derive = config.deriveStartMediaTime ?? deriveSharedMinStartMediaTime;\n\n const selectionContext = (): DeriveStartMediaTimeContext => ({\n selectedVideoTrackId: state.selectedVideoTrackId?.get(),\n selectedAudioTrackId: state.selectedAudioTrackId?.get(),\n });\n\n /** Established once the selected A/V tracks (whichever exist) carry `startMediaTime`. */\n const established = (): boolean => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return false;\n const ids = [state.selectedVideoTrackId?.get(), state.selectedAudioTrackId?.get()].filter(\n (id): id is string => id !== undefined\n );\n return ids.length > 0 && ids.every((id) => findTrackById(presentation, id)?.startMediaTime !== undefined);\n };\n\n return createMachineReactor<EstablishFsmState>({\n initial: 'inactive',\n monitor: () => {\n if (!isResolvedPresentation(state.presentation.get())) return 'inactive';\n return established() ? 'established' : 'monitoring';\n },\n states: {\n // Fresh per source: clear the transient slot so a new source re-discovers.\n inactive: { entry: () => state.mediaContainerData.set(undefined) },\n // The establishment order, as two effects: anchor first, derive second.\n // Both stamps are sticky per source — disabled on entry to `established`,\n // and a new source re-enters through `inactive`.\n monitoring: {\n effects: [\n // Anchor: freeze the wall-clock origin from the reference track's\n // first parse and stamp it onto every other track (the gate's open\n // condition + the `placeOnAnchor` preset for shells). Reads the\n // presentation tracked — it must see the reference resolve — and its\n // own write converges immediately (the stamp is idempotent, so the\n // re-run sets an identical value and stops).\n () => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return;\n const referenceId = referenceTrackId(selectionContext());\n const reference = referenceId === undefined ? undefined : findTrackById(presentation, referenceId);\n if (!reference || !isResolvedTrack(reference)) return;\n const anchor = reference.startDate;\n if (anchor === undefined) return;\n update(state.presentation as Signal<MaybeResolvedPresentation>, (current) =>\n stampStartDates(current as Presentation, anchor)\n );\n },\n // Derive: reduce the accumulating container data into per-track startMediaTime\n // and stamp it onto the model. Re-runs as discover fills the slot; the update\n // reads presentation untracked, so no self-loop.\n () => {\n const containerData = state.mediaContainerData.get();\n if (!containerData) return;\n const startMediaTimes = derive(containerData, selectionContext());\n // `current` is always resolved here — the monitor gates `monitoring` on a\n // resolved presentation and transitions before effects re-run — so we cast\n // to `Presentation` rather than re-narrowing.\n update(state.presentation as Signal<MaybeResolvedPresentation>, (current) =>\n stampTracks(current as Presentation, startMediaTimes)\n );\n },\n ],\n },\n established: {},\n },\n });\n}\n\nexport const establishStartMediaTime: Behavior<\n {\n presentation: Signal<EstablishStartMediaTimeState['presentation']>;\n mediaContainerData: Signal<EstablishStartMediaTimeState['mediaContainerData']>;\n },\n Record<never, never>,\n EstablishStartMediaTimeConfig\n> = {\n stateKeys: ['presentation', 'mediaContainerData'],\n contextKeys: [],\n setup: establishStartMediaTimeSetup,\n};\n"],"mappings":"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"}
@@ -1,16 +1,18 @@
1
1
  import { computed, peek, update } from "../../core/signals/primitives.js";
2
2
  import { defineBehavior } from "../../core/composition/create-composition.js";
3
- import { ConcurrentRunner, Task } from "../../core/tasks/task.js";
3
+ import { RecurringRunner, Task, runOnce } from "../../core/tasks/task.js";
4
4
  import { createMachineReactor } from "../../core/reactors/create-machine-reactor.js";
5
- import { isResolvedPresentation, isResolvedTrack } from "../../media/types/index.js";
5
+ import { deriveStreamType, getMediaPlaylistMetadata, isResolvedPresentation, isResolvedTrack } from "../../media/types/index.js";
6
6
  import { applyContainerMimeType, findTrack, updateTrackInPresentation } from "../../media/utils/tracks.js";
7
7
  import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from "../primitives/track-types.js";
8
8
  import { NON_FMP4_CONTAINER_MIMES, parseMediaPlaylist } from "../../media/hls/parse-media-playlist.js";
9
+ import { emitError } from "./collect-errors.js";
9
10
  import { fetchResolvableText } from "../../network/fetch.js";
10
11
  import { failoverFetch } from "../primitives/failover-fetch.js";
12
+ import { when } from "../../core/signals/when.js";
11
13
  //#region src/playback/behaviors/resolve-track.ts
12
- function setupTrackResolution({ state, config: { selectedKey, findTrackToResolve, fetchResolvableText: fetchResolvableText$1 = fetchResolvableText } }) {
13
- const runner = new ConcurrentRunner();
14
+ function setupTrackResolution({ state, config: { selectedKey, findTrackToResolve, fetchResolvableText: fetchResolvableText$1 = fetchResolvableText, gateFirstParse, reschedule, reportUnsupportedTrackConditions } }) {
15
+ const runner = new RecurringRunner(reschedule ?? runOnce);
14
16
  const derivedStateSignal = computed(() => isResolvedPresentation(state.presentation.get()) ? "presentation-resolved" : "presentation-unresolved");
15
17
  return createMachineReactor({
16
18
  initial: "presentation-unresolved",
@@ -24,15 +26,34 @@ function setupTrackResolution({ state, config: { selectedKey, findTrackToResolve
24
26
  const trackId = state[selectedKey].get();
25
27
  if (!presentation || !trackId) return;
26
28
  const track = findTrackToResolve(presentation, trackId);
27
- if (!track || isResolvedTrack(track)) return;
29
+ if (!track || isResolvedTrack(track) && Number.isFinite(track.duration)) return;
28
30
  runner.schedule(new Task(async (signal) => {
29
- const mediaTrack = parseMediaPlaylist(await fetchResolvableText$1(track, { signal }), track);
31
+ const snapshot = peek(state.presentation);
32
+ const current = snapshot ? findTrackToResolve(snapshot, trackId) : void 0;
33
+ if (!current) throw new Error("resolve-track: selected track not found");
34
+ const text = await fetchResolvableText$1(current, { signal });
35
+ if (gateFirstParse && !isResolvedTrack(current)) {
36
+ const { selectedVideoTrackId, selectedAudioTrackId } = state;
37
+ await when(() => gateFirstParse(state.presentation.get(), {
38
+ selectedVideoTrackId: selectedVideoTrackId?.get(),
39
+ selectedAudioTrackId: selectedAudioTrackId?.get()
40
+ }, trackId), { signal });
41
+ }
42
+ const live = peek(state.presentation);
43
+ const previous = live ? findTrackToResolve(live, trackId) : void 0;
44
+ if (!previous) throw new Error("resolve-track: selected track not found");
45
+ const mediaTrack = parseMediaPlaylist(text, previous);
46
+ if (reportUnsupportedTrackConditions) for (const condition of reportUnsupportedTrackConditions(mediaTrack)) emitError(state, condition);
30
47
  update(state.presentation, (current) => {
31
48
  if (!isResolvedPresentation(current)) return current;
32
49
  const patched = updateTrackInPresentation(current, mediaTrack);
33
- return NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType) ? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType) : patched;
50
+ return {
51
+ ...NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType) ? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType) : patched,
52
+ streamType: deriveStreamType(getMediaPlaylistMetadata(mediaTrack))
53
+ };
34
54
  });
35
- }, { id: track.id }));
55
+ return mediaTrack;
56
+ }, { id: track.id })).catch(() => {});
36
57
  }]
37
58
  }
38
59
  }