@videojs/spf 10.0.0-beta.24 → 10.0.0-beta.25

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 (180) hide show
  1. package/dist/default/background-video.js +3 -0
  2. package/dist/default/core/composition/create-composition.js +1 -1
  3. package/dist/default/core/composition/create-composition.js.map +1 -1
  4. package/dist/default/core/composition/share-signals.js +13 -11
  5. package/dist/default/core/composition/share-signals.js.map +1 -1
  6. package/dist/default/hls.js +3 -1
  7. package/dist/default/media/abr/quality-selection.js +6 -68
  8. package/dist/default/media/abr/quality-selection.js.map +1 -1
  9. package/dist/default/media/dom/capabilities.js +70 -0
  10. package/dist/default/media/dom/capabilities.js.map +1 -0
  11. package/dist/default/media/dom/mse/mediasource-setup.js +1 -1
  12. package/dist/default/media/dom/text/text-track-slots.js +3 -3
  13. package/dist/default/media/dom/text/text-track-slots.js.map +1 -1
  14. package/dist/default/media/hls/parse-attributes.js +16 -2
  15. package/dist/default/media/hls/parse-attributes.js.map +1 -1
  16. package/dist/default/media/hls/parse-media-playlist.js +25 -2
  17. package/dist/default/media/hls/parse-media-playlist.js.map +1 -1
  18. package/dist/default/media/hls/parse-multivariant.js +15 -6
  19. package/dist/default/media/hls/parse-multivariant.js.map +1 -1
  20. package/dist/default/media/primitives/select-tracks.js +98 -13
  21. package/dist/default/media/primitives/select-tracks.js.map +1 -1
  22. package/dist/default/media/types/index.js.map +1 -1
  23. package/dist/default/media/utils/cdn.js +56 -0
  24. package/dist/default/media/utils/cdn.js.map +1 -0
  25. package/dist/default/media/utils/tracks.js +28 -1
  26. package/dist/default/media/utils/tracks.js.map +1 -1
  27. package/dist/default/network/fetch.js +7 -1
  28. package/dist/default/network/fetch.js.map +1 -1
  29. package/dist/default/playback/actors/dom/segment-loader.js +42 -11
  30. package/dist/default/playback/actors/dom/segment-loader.js.map +1 -1
  31. package/dist/default/playback/actors/dom/source-buffer.js +2 -1
  32. package/dist/default/playback/actors/dom/source-buffer.js.map +1 -1
  33. package/dist/default/playback/behaviors/calculate-presentation-duration.js +21 -43
  34. package/dist/default/playback/behaviors/calculate-presentation-duration.js.map +1 -1
  35. package/dist/default/playback/behaviors/derive-cdn-priority.js +68 -0
  36. package/dist/default/playback/behaviors/derive-cdn-priority.js.map +1 -0
  37. package/dist/default/playback/behaviors/dom/end-of-stream.js +15 -103
  38. package/dist/default/playback/behaviors/dom/end-of-stream.js.map +1 -1
  39. package/dist/default/playback/behaviors/dom/load-segments.js +1 -1
  40. package/dist/default/playback/behaviors/dom/load-segments.js.map +1 -1
  41. package/dist/default/playback/behaviors/dom/setup-buffer-actors.js +33 -14
  42. package/dist/default/playback/behaviors/dom/setup-buffer-actors.js.map +1 -1
  43. package/dist/default/playback/behaviors/dom/sync-text-tracks.js +43 -18
  44. package/dist/default/playback/behaviors/dom/sync-text-tracks.js.map +1 -1
  45. package/dist/default/playback/behaviors/dom/track-current-time.js +6 -0
  46. package/dist/default/playback/behaviors/dom/track-current-time.js.map +1 -1
  47. package/dist/default/playback/behaviors/dom/update-mediasource-duration.js +1 -1
  48. package/dist/default/playback/behaviors/resolve-track.js +45 -22
  49. package/dist/default/playback/behaviors/resolve-track.js.map +1 -1
  50. package/dist/default/playback/behaviors/select-tracks.js +46 -77
  51. package/dist/default/playback/behaviors/select-tracks.js.map +1 -1
  52. package/dist/default/playback/behaviors/setup-failover-monitor.js +66 -0
  53. package/dist/default/playback/behaviors/setup-failover-monitor.js.map +1 -0
  54. package/dist/default/playback/behaviors/track-switching.js +341 -0
  55. package/dist/default/playback/behaviors/track-switching.js.map +1 -0
  56. package/dist/default/playback/engines/background-video/adapter.js +158 -0
  57. package/dist/default/playback/engines/background-video/adapter.js.map +1 -0
  58. package/dist/default/playback/engines/background-video/engine.js +75 -0
  59. package/dist/default/playback/engines/background-video/engine.js.map +1 -0
  60. package/dist/default/playback/engines/hls/adapter-audio-only.js +119 -0
  61. package/dist/default/playback/engines/hls/adapter-audio-only.js.map +1 -0
  62. package/dist/default/playback/engines/hls/adapter.js +7 -0
  63. package/dist/default/playback/engines/hls/adapter.js.map +1 -1
  64. package/dist/default/playback/engines/hls/engine-audio-only.js +80 -0
  65. package/dist/default/playback/engines/hls/engine-audio-only.js.map +1 -0
  66. package/dist/default/playback/engines/hls/engine.js +20 -7
  67. package/dist/default/playback/engines/hls/engine.js.map +1 -1
  68. package/dist/default/playback/primitives/failover-fetch.js +42 -0
  69. package/dist/default/playback/primitives/failover-fetch.js.map +1 -0
  70. package/dist/{dev/playback/behaviors → default/playback/primitives}/track-types.js +1 -1
  71. package/dist/default/playback/primitives/track-types.js.map +1 -0
  72. package/dist/dev/background-video.d.ts +3 -0
  73. package/dist/dev/background-video.js +3 -0
  74. package/dist/dev/core/composition/create-composition.js +1 -1
  75. package/dist/dev/core/composition/create-composition.js.map +1 -1
  76. package/dist/dev/core/composition/share-signals.d.ts +11 -9
  77. package/dist/dev/core/composition/share-signals.d.ts.map +1 -1
  78. package/dist/dev/core/composition/share-signals.js +13 -11
  79. package/dist/dev/core/composition/share-signals.js.map +1 -1
  80. package/dist/dev/hls.d.ts +3 -1
  81. package/dist/dev/hls.js +3 -1
  82. package/dist/dev/media/abr/quality-selection.js +6 -68
  83. package/dist/dev/media/abr/quality-selection.js.map +1 -1
  84. package/dist/dev/media/dom/capabilities.js +70 -0
  85. package/dist/dev/media/dom/capabilities.js.map +1 -0
  86. package/dist/dev/media/dom/mse/mediasource-setup.js +1 -1
  87. package/dist/dev/media/dom/text/text-track-slots.d.ts.map +1 -1
  88. package/dist/dev/media/dom/text/text-track-slots.js +3 -3
  89. package/dist/dev/media/dom/text/text-track-slots.js.map +1 -1
  90. package/dist/dev/media/hls/parse-attributes.js +16 -2
  91. package/dist/dev/media/hls/parse-attributes.js.map +1 -1
  92. package/dist/dev/media/hls/parse-media-playlist.js +25 -2
  93. package/dist/dev/media/hls/parse-media-playlist.js.map +1 -1
  94. package/dist/dev/media/hls/parse-multivariant.js +15 -6
  95. package/dist/dev/media/hls/parse-multivariant.js.map +1 -1
  96. package/dist/dev/media/primitives/select-tracks.d.ts +36 -0
  97. package/dist/dev/media/primitives/select-tracks.d.ts.map +1 -0
  98. package/dist/dev/media/primitives/select-tracks.js +98 -13
  99. package/dist/dev/media/primitives/select-tracks.js.map +1 -1
  100. package/dist/dev/media/types/index.d.ts +26 -2
  101. package/dist/dev/media/types/index.d.ts.map +1 -1
  102. package/dist/dev/media/types/index.js.map +1 -1
  103. package/dist/dev/media/utils/cdn.d.ts +13 -0
  104. package/dist/dev/media/utils/cdn.d.ts.map +1 -0
  105. package/dist/dev/media/utils/cdn.js +56 -0
  106. package/dist/dev/media/utils/cdn.js.map +1 -0
  107. package/dist/dev/media/utils/tracks.js +28 -1
  108. package/dist/dev/media/utils/tracks.js.map +1 -1
  109. package/dist/dev/network/fetch.js +7 -1
  110. package/dist/dev/network/fetch.js.map +1 -1
  111. package/dist/dev/playback/actors/dom/segment-loader.js +42 -11
  112. package/dist/dev/playback/actors/dom/segment-loader.js.map +1 -1
  113. package/dist/dev/playback/actors/dom/source-buffer.d.ts +15 -0
  114. package/dist/dev/playback/actors/dom/source-buffer.d.ts.map +1 -1
  115. package/dist/dev/playback/actors/dom/source-buffer.js +2 -1
  116. package/dist/dev/playback/actors/dom/source-buffer.js.map +1 -1
  117. package/dist/dev/playback/behaviors/calculate-presentation-duration.d.ts +8 -0
  118. package/dist/dev/playback/behaviors/calculate-presentation-duration.d.ts.map +1 -1
  119. package/dist/dev/playback/behaviors/calculate-presentation-duration.js +21 -43
  120. package/dist/dev/playback/behaviors/calculate-presentation-duration.js.map +1 -1
  121. package/dist/dev/playback/behaviors/derive-cdn-priority.js +68 -0
  122. package/dist/dev/playback/behaviors/derive-cdn-priority.js.map +1 -0
  123. package/dist/dev/playback/behaviors/dom/end-of-stream.js +15 -103
  124. package/dist/dev/playback/behaviors/dom/end-of-stream.js.map +1 -1
  125. package/dist/dev/playback/behaviors/dom/load-segments.js +1 -1
  126. package/dist/dev/playback/behaviors/dom/load-segments.js.map +1 -1
  127. package/dist/dev/playback/behaviors/dom/setup-buffer-actors.js +33 -14
  128. package/dist/dev/playback/behaviors/dom/setup-buffer-actors.js.map +1 -1
  129. package/dist/dev/playback/behaviors/dom/sync-text-tracks.js +43 -18
  130. package/dist/dev/playback/behaviors/dom/sync-text-tracks.js.map +1 -1
  131. package/dist/dev/playback/behaviors/dom/track-current-time.d.ts.map +1 -1
  132. package/dist/dev/playback/behaviors/dom/track-current-time.js +6 -0
  133. package/dist/dev/playback/behaviors/dom/track-current-time.js.map +1 -1
  134. package/dist/dev/playback/behaviors/dom/update-mediasource-duration.js +1 -1
  135. package/dist/dev/playback/behaviors/resolve-track.js +45 -22
  136. package/dist/dev/playback/behaviors/resolve-track.js.map +1 -1
  137. package/dist/dev/playback/behaviors/select-tracks.d.ts +13 -0
  138. package/dist/dev/playback/behaviors/select-tracks.d.ts.map +1 -0
  139. package/dist/dev/playback/behaviors/select-tracks.js +46 -77
  140. package/dist/dev/playback/behaviors/select-tracks.js.map +1 -1
  141. package/dist/dev/playback/behaviors/setup-failover-monitor.d.ts +12 -0
  142. package/dist/dev/playback/behaviors/setup-failover-monitor.d.ts.map +1 -0
  143. package/dist/dev/playback/behaviors/setup-failover-monitor.js +66 -0
  144. package/dist/dev/playback/behaviors/setup-failover-monitor.js.map +1 -0
  145. package/dist/dev/playback/behaviors/track-switching.js +341 -0
  146. package/dist/dev/playback/behaviors/track-switching.js.map +1 -0
  147. package/dist/dev/playback/engines/background-video/adapter.d.ts +60 -0
  148. package/dist/dev/playback/engines/background-video/adapter.d.ts.map +1 -0
  149. package/dist/dev/playback/engines/background-video/adapter.js +158 -0
  150. package/dist/dev/playback/engines/background-video/adapter.js.map +1 -0
  151. package/dist/dev/playback/engines/background-video/engine.d.ts +107 -0
  152. package/dist/dev/playback/engines/background-video/engine.d.ts.map +1 -0
  153. package/dist/dev/playback/engines/background-video/engine.js +75 -0
  154. package/dist/dev/playback/engines/background-video/engine.js.map +1 -0
  155. package/dist/dev/playback/engines/hls/adapter-audio-only.d.ts +45 -0
  156. package/dist/dev/playback/engines/hls/adapter-audio-only.d.ts.map +1 -0
  157. package/dist/dev/playback/engines/hls/adapter-audio-only.js +119 -0
  158. package/dist/dev/playback/engines/hls/adapter-audio-only.js.map +1 -0
  159. package/dist/dev/playback/engines/hls/adapter.d.ts.map +1 -1
  160. package/dist/dev/playback/engines/hls/adapter.js +7 -0
  161. package/dist/dev/playback/engines/hls/adapter.js.map +1 -1
  162. package/dist/dev/playback/engines/hls/engine-audio-only.d.ts +127 -0
  163. package/dist/dev/playback/engines/hls/engine-audio-only.d.ts.map +1 -0
  164. package/dist/dev/playback/engines/hls/engine-audio-only.js +80 -0
  165. package/dist/dev/playback/engines/hls/engine-audio-only.js.map +1 -0
  166. package/dist/dev/playback/engines/hls/engine.d.ts +61 -2
  167. package/dist/dev/playback/engines/hls/engine.d.ts.map +1 -1
  168. package/dist/dev/playback/engines/hls/engine.js +20 -7
  169. package/dist/dev/playback/engines/hls/engine.js.map +1 -1
  170. package/dist/dev/playback/primitives/failover-fetch.js +42 -0
  171. package/dist/dev/playback/primitives/failover-fetch.js.map +1 -0
  172. package/dist/{default/playback/behaviors → dev/playback/primitives}/track-types.js +1 -1
  173. package/dist/dev/playback/primitives/track-types.js.map +1 -0
  174. package/package.json +7 -2
  175. package/dist/default/playback/behaviors/quality-switching.js +0 -96
  176. package/dist/default/playback/behaviors/quality-switching.js.map +0 -1
  177. package/dist/default/playback/behaviors/track-types.js.map +0 -1
  178. package/dist/dev/playback/behaviors/quality-switching.js +0 -96
  179. package/dist/dev/playback/behaviors/quality-switching.js.map +0 -1
  180. package/dist/dev/playback/behaviors/track-types.js.map +0 -1
@@ -1,106 +1,12 @@
1
1
  import { computed, signal } from "../../../core/signals/primitives.js";
2
- import { defineBehavior } from "../../../core/composition/create-composition.js";
3
2
  import { effect } from "../../../core/signals/effect.js";
4
3
  import { createMachineReactor } from "../../../core/reactors/create-machine-reactor.js";
5
4
  import { isResolvedTrack } from "../../../media/types/index.js";
6
5
  import { findTrackById } from "../../../media/utils/tracks.js";
6
+ import { onMediaSourceReadyStateChange } from "../../../media/dom/mse/mediasource-setup.js";
7
7
  import { getMaxBufferedEnd, waitForSourceBuffersReady } from "../../../media/dom/mse/duration.js";
8
8
  import { isLastSegmentAppended } from "../../../media/dom/mse/end-of-stream.js";
9
- import { onMediaSourceReadyStateChange } from "../../../media/dom/mse/mediasource-setup.js";
10
9
  //#region src/playback/behaviors/dom/end-of-stream.ts
11
- /**
12
- * **Drive each `open → ended` transition of the MediaSource.** Calls
13
- * `MediaSource.endOfStream()` once the temporally last segments of every
14
- * active buffer actor's currently-loading track are fully appended and
15
- * the user has reached them — letting the browser finalize duration and
16
- * fire `ended` on the media element.
17
- *
18
- * Re-fires on every subsequent `open → ended → open` cycle. Per the MSE
19
- * spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource
20
- * back to `'open'`; seek-back replays and back-buffer refills that re-load
21
- * earlier segments take this path, so the behavior must call
22
- * `endOfStream()` again once the last segments are reappended. The reactor's
23
- * `'preconditions-unmet'` ↔ `'eos-ready'` cycle *is* the re-arm mechanism:
24
- * a successful `endOfStream()` flips `mediaSource.readyState` to `'ended'`,
25
- * which (via the local `msIsOpen` signal) exits `'eos-ready'`; the next
26
- * `'open'` re-evaluates preconditions and may re-enter.
27
- *
28
- * # Tracking what's in the buffer
29
- *
30
- * Each `SourceBufferActor` knows which track it's currently loading via
31
- * `initTrackId` (set on the most recent `append-init` message) and which
32
- * segments it has appended. `deriveState` iterates over the available
33
- * buffer actors (`[videoBufferActor, audioBufferActor].filter(Boolean)`),
34
- * resolves each to its track via `findTrackById`, and checks that track's
35
- * last segment is appended. This means audio-only / video-only / mixed
36
- * configurations compose uniformly — the body iterates whatever's in
37
- * scope. No reliance on `selectedTrackId` slots: the actor's view IS the
38
- * source of truth for "what's being loaded into this buffer."
39
- *
40
- * **Two-fire on mid-end ABR switches**: when a quality switch occurs near
41
- * end-of-stream, the actor's `initTrackId` still reflects the *old* track
42
- * until the new init segment is appended. The reactor may fire
43
- * `endOfStream()` against the old track's last-segment-appended state,
44
- * then the new init's `appendBuffer()` re-opens the MS, which re-arms us
45
- * to fire again once the new track's last segment lands. Functionally
46
- * correct (the browser re-fires `ended` after the re-arm), at the cost of
47
- * one extra call. Accepted as the price of dropping `selectedTrackId`
48
- * dependence.
49
- *
50
- * # Buffer-actor idle gate
51
- *
52
- * Each actor must be `'idle'` (no queued or in-flight tasks) before we
53
- * fire. `buffer.updating === false` alone is insufficient: for chunked
54
- * fMP4 streaming, `updateend` fires after each chunk while the actor's
55
- * for-await loop synchronously enqueues the next `appendBuffer()` — so
56
- * the buffer flips `!updating → updating` across a microtask boundary.
57
- * The actor's `'updating'` state spans the entire multi-chunk append, so
58
- * `actor.snapshot.value === 'idle'` is the canonical "no more pending or
59
- * in-flight work" oracle.
60
- *
61
- * This bet (actor models the pipeline; `mediaSource.sourceBuffers` models
62
- * DOM state) is durable as long as the segment loader is the sole writer
63
- * of `appendBuffer()` calls. A future loop-mode that auto-fetches earlier
64
- * segments mid-`ended` would require a broader coordination story
65
- * between SourceBuffers and the MediaSource — possibly via a
66
- * `MediaSourceActor` — and that's where this gate would want
67
- * re-evaluating.
68
- *
69
- * # currentTime gate
70
- *
71
- * `currentTime` must have reached at least one active track's last
72
- * segment startTime. Prevents `'eos-ready'` entry when a back-buffer
73
- * `remove()` / `appendBuffer()` briefly re-opens the MediaSource while
74
- * the user is mid-stream. HLS rendition time-alignment means any active
75
- * track works as the reference.
76
- *
77
- * # MS readyState — local subscription
78
- *
79
- * The behavior subscribes to `mediaSource`'s readyState changes inside
80
- * its setup (via `onMediaSourceReadyStateChange`) and mirrors `'open'` to
81
- * a behavior-local `msIsOpen` signal that `deriveState` reads. No shared
82
- * `mediaSourceReadyState` slot dependency. Anticipates a future
83
- * `MediaSourceActor` whose snapshot would expose the same signal — the
84
- * consumer code wouldn't change.
85
- *
86
- * # `endOfStream` entry sequence
87
- *
88
- * 1. Wait for all SourceBuffers to be idle (DOM-level — defensive,
89
- * should already hold given the actor-idle gate in `deriveState`).
90
- * 2. Set `mediaSource.duration` from `getMaxBufferedEnd` to match actual
91
- * container timestamps (`endOfStream()` only clamps up implicitly;
92
- * setting it explicitly here keeps the final value deterministic).
93
- * 3. Call `mediaSource.endOfStream()`.
94
- *
95
- * State-exit cleanup (`controller.abort()`) cancels any in-flight wait
96
- * on source unload, presentation replace, or behavior destroy.
97
- *
98
- * # Coordination with `updateMediaSourceDuration`
99
- *
100
- * `updateMediaSourceDuration` writes the initial `mediaSource.duration`
101
- * from `presentation.duration` once per source; this behavior writes the
102
- * final value from the buffered end. Decision domains don't overlap.
103
- */
104
10
  function deriveState(presentation, mediaSource, msIsOpen, videoBufferActor, audioBufferActor, currentTime) {
105
11
  if (!mediaSource || !presentation || !msIsOpen) return "preconditions-unmet";
106
12
  const actors = [videoBufferActor, audioBufferActor].filter((a) => a !== void 0);
@@ -137,7 +43,7 @@ function endOfStreamSetup({ state, context }) {
137
43
  });
138
44
  return () => controller.abort();
139
45
  });
140
- const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaSource.get(), msIsOpen.get(), context.videoBufferActor.get(), context.audioBufferActor.get(), state.currentTime.get()));
46
+ const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaSource.get(), msIsOpen.get(), context.videoBufferActor?.get(), context.audioBufferActor?.get(), state.currentTime.get()));
141
47
  const reactor = createMachineReactor({
142
48
  initial: "preconditions-unmet",
143
49
  monitor: () => derivedStateSignal.get(),
@@ -163,15 +69,21 @@ function endOfStreamSetup({ state, context }) {
163
69
  reactor.destroy();
164
70
  };
165
71
  }
166
- const endOfStream = defineBehavior({
72
+ /**
73
+ * `endOfStream` uses a manual `Behavior<>` literal (rather than
74
+ * `defineBehavior`) because it reads `videoBufferActor` /
75
+ * `audioBufferActor` defensively without declaring them in its
76
+ * contextKeys — those slots are contributed by other behaviors and
77
+ * compose conditionally per engine variant. The `Behavior<>` literal
78
+ * opts out of the exhaustiveness check so the typed context shape can
79
+ * include the optional fields used at runtime. See the comment on
80
+ * `endOfStreamSetup`'s context param for the discipline.
81
+ */
82
+ const endOfStream = {
167
83
  stateKeys: ["presentation", "currentTime"],
168
- contextKeys: [
169
- "mediaSource",
170
- "videoBufferActor",
171
- "audioBufferActor"
172
- ],
84
+ contextKeys: ["mediaSource"],
173
85
  setup: endOfStreamSetup
174
- });
86
+ };
175
87
  //#endregion
176
88
  export { endOfStream };
177
89
 
@@ -1 +1 @@
1
- {"version":3,"file":"end-of-stream.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/end-of-stream.ts"],"sourcesContent":["/**\n * **Drive each `open → ended` transition of the MediaSource.** Calls\n * `MediaSource.endOfStream()` once the temporally last segments of every\n * active buffer actor's currently-loading track are fully appended and\n * the user has reached them — letting the browser finalize duration and\n * fire `ended` on the media element.\n *\n * Re-fires on every subsequent `open → ended → open` cycle. Per the MSE\n * spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource\n * back to `'open'`; seek-back replays and back-buffer refills that re-load\n * earlier segments take this path, so the behavior must call\n * `endOfStream()` again once the last segments are reappended. The reactor's\n * `'preconditions-unmet'` ↔ `'eos-ready'` cycle *is* the re-arm mechanism:\n * a successful `endOfStream()` flips `mediaSource.readyState` to `'ended'`,\n * which (via the local `msIsOpen` signal) exits `'eos-ready'`; the next\n * `'open'` re-evaluates preconditions and may re-enter.\n *\n * # Tracking what's in the buffer\n *\n * Each `SourceBufferActor` knows which track it's currently loading via\n * `initTrackId` (set on the most recent `append-init` message) and which\n * segments it has appended. `deriveState` iterates over the available\n * buffer actors (`[videoBufferActor, audioBufferActor].filter(Boolean)`),\n * resolves each to its track via `findTrackById`, and checks that track's\n * last segment is appended. This means audio-only / video-only / mixed\n * configurations compose uniformly — the body iterates whatever's in\n * scope. No reliance on `selectedTrackId` slots: the actor's view IS the\n * source of truth for \"what's being loaded into this buffer.\"\n *\n * **Two-fire on mid-end ABR switches**: when a quality switch occurs near\n * end-of-stream, the actor's `initTrackId` still reflects the *old* track\n * until the new init segment is appended. The reactor may fire\n * `endOfStream()` against the old track's last-segment-appended state,\n * then the new init's `appendBuffer()` re-opens the MS, which re-arms us\n * to fire again once the new track's last segment lands. Functionally\n * correct (the browser re-fires `ended` after the re-arm), at the cost of\n * one extra call. Accepted as the price of dropping `selectedTrackId`\n * dependence.\n *\n * # Buffer-actor idle gate\n *\n * Each actor must be `'idle'` (no queued or in-flight tasks) before we\n * fire. `buffer.updating === false` alone is insufficient: for chunked\n * fMP4 streaming, `updateend` fires after each chunk while the actor's\n * for-await loop synchronously enqueues the next `appendBuffer()` — so\n * the buffer flips `!updating → updating` across a microtask boundary.\n * The actor's `'updating'` state spans the entire multi-chunk append, so\n * `actor.snapshot.value === 'idle'` is the canonical \"no more pending or\n * in-flight work\" oracle.\n *\n * This bet (actor models the pipeline; `mediaSource.sourceBuffers` models\n * DOM state) is durable as long as the segment loader is the sole writer\n * of `appendBuffer()` calls. A future loop-mode that auto-fetches earlier\n * segments mid-`ended` would require a broader coordination story\n * between SourceBuffers and the MediaSource — possibly via a\n * `MediaSourceActor` — and that's where this gate would want\n * re-evaluating.\n *\n * # currentTime gate\n *\n * `currentTime` must have reached at least one active track's last\n * segment startTime. Prevents `'eos-ready'` entry when a back-buffer\n * `remove()` / `appendBuffer()` briefly re-opens the MediaSource while\n * the user is mid-stream. HLS rendition time-alignment means any active\n * track works as the reference.\n *\n * # MS readyState — local subscription\n *\n * The behavior subscribes to `mediaSource`'s readyState changes inside\n * its setup (via `onMediaSourceReadyStateChange`) and mirrors `'open'` to\n * a behavior-local `msIsOpen` signal that `deriveState` reads. No shared\n * `mediaSourceReadyState` slot dependency. Anticipates a future\n * `MediaSourceActor` whose snapshot would expose the same signal — the\n * consumer code wouldn't change.\n *\n * # `endOfStream` entry sequence\n *\n * 1. Wait for all SourceBuffers to be idle (DOM-level — defensive,\n * should already hold given the actor-idle gate in `deriveState`).\n * 2. Set `mediaSource.duration` from `getMaxBufferedEnd` to match actual\n * container timestamps (`endOfStream()` only clamps up implicitly;\n * setting it explicitly here keeps the final value deterministic).\n * 3. Call `mediaSource.endOfStream()`.\n *\n * State-exit cleanup (`controller.abort()`) cancels any in-flight wait\n * on source unload, presentation replace, or behavior destroy.\n *\n * # Coordination with `updateMediaSourceDuration`\n *\n * `updateMediaSourceDuration` writes the initial `mediaSource.duration`\n * from `presentation.duration` once per source; this behavior writes the\n * final value from the buffered end. Decision domains don't overlap.\n */\nimport { defineBehavior } from '../../../core/composition/create-composition';\nimport { createMachineReactor } from '../../../core/reactors/create-machine-reactor';\nimport { effect } from '../../../core/signals/effect';\nimport { computed, type ReadonlySignal, signal } from '../../../core/signals/primitives';\nimport { getMaxBufferedEnd, waitForSourceBuffersReady } from '../../../media/dom/mse/duration';\nimport { isLastSegmentAppended } from '../../../media/dom/mse/end-of-stream';\nimport { onMediaSourceReadyStateChange } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\nimport { isResolvedTrack } from '../../../media/types';\nimport { findTrackById } from '../../../media/utils/tracks';\nimport type { SourceBufferActor } from '../../actors/dom/source-buffer';\n\nexport interface EndOfStreamState {\n presentation?: MaybeResolvedPresentation;\n currentTime?: number;\n}\n\nexport interface EndOfStreamContext {\n mediaSource?: MediaSource;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\ntype EndOfStreamFsmState = 'preconditions-unmet' | 'eos-ready';\n\nfunction deriveState(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined,\n msIsOpen: boolean,\n videoBufferActor: SourceBufferActor | undefined,\n audioBufferActor: SourceBufferActor | undefined,\n currentTime: number | undefined\n): EndOfStreamFsmState {\n if (!mediaSource || !presentation || !msIsOpen) return 'preconditions-unmet';\n\n const actors = [videoBufferActor, audioBufferActor].filter((a): a is SourceBufferActor => a !== undefined);\n // No active buffer actors means setup hasn't completed wiring yet.\n if (actors.length === 0) return 'preconditions-unmet';\n\n // Track the latest last-segment startTime across actors as the\n // currentTime reference. HLS rendition time-alignment makes any active\n // track usable; using the max is safest when timings drift slightly.\n let lastSegStart: number | undefined;\n\n for (const actor of actors) {\n const snapshot = actor.snapshot.get();\n if (snapshot.value !== 'idle') return 'preconditions-unmet';\n\n const { initTrackId, segments: appended } = snapshot.context;\n if (!initTrackId) return 'preconditions-unmet';\n\n const track = findTrackById(presentation, initTrackId);\n if (!track || !isResolvedTrack(track)) return 'preconditions-unmet';\n if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet';\n\n if (track.segments.length > 0) {\n const start = track.segments[track.segments.length - 1]!.startTime;\n if (lastSegStart === undefined || start > lastSegStart) lastSegStart = start;\n }\n }\n\n if (lastSegStart !== undefined && (currentTime ?? 0) < lastSegStart) {\n return 'preconditions-unmet';\n }\n\n return 'eos-ready';\n}\n\nfunction endOfStreamSetup({\n state,\n context,\n}: {\n state: {\n presentation: ReadonlySignal<EndOfStreamState['presentation']>;\n currentTime: ReadonlySignal<EndOfStreamState['currentTime']>;\n };\n context: {\n mediaSource: ReadonlySignal<EndOfStreamContext['mediaSource']>;\n videoBufferActor: ReadonlySignal<EndOfStreamContext['videoBufferActor']>;\n audioBufferActor: ReadonlySignal<EndOfStreamContext['audioBufferActor']>;\n };\n}): () => void {\n // Behavior-local mirror of `mediaSource.readyState === 'open'`. Subscribes\n // to MS events whenever a MediaSource is in scope; tears down when the\n // MS is swapped or the behavior destroys. Anticipates a future\n // `MediaSourceActor` whose snapshot would expose the same value — the\n // consumer code (deriveState) wouldn't change.\n const msIsOpen = signal(false);\n const cleanupMsListener = effect(() => {\n const mediaSource = context.mediaSource.get();\n if (!mediaSource) {\n msIsOpen.set(false);\n return;\n }\n msIsOpen.set(mediaSource.readyState === 'open');\n const controller = new AbortController();\n onMediaSourceReadyStateChange(mediaSource, controller.signal, (rs) => {\n msIsOpen.set(rs === 'open');\n });\n return () => controller.abort();\n });\n\n const derivedStateSignal = computed(() =>\n deriveState(\n state.presentation.get(),\n context.mediaSource.get(),\n msIsOpen.get(),\n context.videoBufferActor.get(),\n context.audioBufferActor.get(),\n state.currentTime.get()\n )\n );\n\n const reactor = createMachineReactor<EndOfStreamFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'eos-ready': {\n // entry body is auto-untracked. deriveState handles source resets\n // and the MS readyState flip from 'open' to 'ended' that\n // endOfStream() produces; this entry resolves the MSE-spec\n // preconditions and binds its cleanup (abort) to state exit +\n // destroy.\n entry: () => {\n const mediaSource = context.mediaSource.get()!;\n const controller = new AbortController();\n\n const endStreamWhenReady = async () => {\n // Defensive: the actor-idle gate in deriveState should mean\n // buffers are already settled, but a microtask boundary\n // between deriveState's read and entry firing leaves a thin\n // gap. The DOM-level wait closes it.\n await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);\n if (controller.signal.aborted) return;\n\n // MSE spec: duration cannot be less than any buffered range, and\n // endOfStream() will only clamp up implicitly. Setting it\n // explicitly here from actual container timestamps keeps the\n // final value deterministic for assets whose declared duration\n // disagrees with the buffered end (common with CMAF).\n const bufferedEnd = getMaxBufferedEnd(mediaSource.sourceBuffers);\n if (bufferedEnd > 0) mediaSource.duration = bufferedEnd;\n\n mediaSource.endOfStream();\n };\n\n endStreamWhenReady().catch((err) => console.error('Failed to call endOfStream:', err));\n\n return () => controller.abort();\n },\n },\n },\n });\n\n return () => {\n cleanupMsListener();\n reactor.destroy();\n };\n}\n\nexport const endOfStream = defineBehavior({\n stateKeys: ['presentation', 'currentTime'],\n contextKeys: ['mediaSource', 'videoBufferActor', 'audioBufferActor'],\n setup: endOfStreamSetup,\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsHA,SAAS,YACP,cACA,aACA,UACA,kBACA,kBACA,aACqB;AACrB,KAAI,CAAC,eAAe,CAAC,gBAAgB,CAAC,SAAU,QAAO;CAEvD,MAAM,SAAS,CAAC,kBAAkB,iBAAiB,CAAC,QAAQ,MAA8B,MAAM,KAAA,EAAU;AAE1G,KAAI,OAAO,WAAW,EAAG,QAAO;CAKhC,IAAI;AAEJ,MAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,WAAW,MAAM,SAAS,KAAK;AACrC,MAAI,SAAS,UAAU,OAAQ,QAAO;EAEtC,MAAM,EAAE,aAAa,UAAU,aAAa,SAAS;AACrD,MAAI,CAAC,YAAa,QAAO;EAEzB,MAAM,QAAQ,cAAc,cAAc,YAAY;AACtD,MAAI,CAAC,SAAS,CAAC,gBAAgB,MAAM,CAAE,QAAO;AAC9C,MAAI,CAAC,sBAAsB,MAAM,UAAU,SAAS,CAAE,QAAO;AAE7D,MAAI,MAAM,SAAS,SAAS,GAAG;GAC7B,MAAM,QAAQ,MAAM,SAAS,MAAM,SAAS,SAAS,GAAI;AACzD,OAAI,iBAAiB,KAAA,KAAa,QAAQ,aAAc,gBAAe;;;AAI3E,KAAI,iBAAiB,KAAA,MAAc,eAAe,KAAK,aACrD,QAAO;AAGT,QAAO;;AAGT,SAAS,iBAAiB,EACxB,OACA,WAWa;CAMb,MAAM,WAAW,OAAO,MAAM;CAC9B,MAAM,oBAAoB,aAAa;EACrC,MAAM,cAAc,QAAQ,YAAY,KAAK;AAC7C,MAAI,CAAC,aAAa;AAChB,YAAS,IAAI,MAAM;AACnB;;AAEF,WAAS,IAAI,YAAY,eAAe,OAAO;EAC/C,MAAM,aAAa,IAAI,iBAAiB;AACxC,gCAA8B,aAAa,WAAW,SAAS,OAAO;AACpE,YAAS,IAAI,OAAO,OAAO;IAC3B;AACF,eAAa,WAAW,OAAO;GAC/B;CAEF,MAAM,qBAAqB,eACzB,YACE,MAAM,aAAa,KAAK,EACxB,QAAQ,YAAY,KAAK,EACzB,SAAS,KAAK,EACd,QAAQ,iBAAiB,KAAK,EAC9B,QAAQ,iBAAiB,KAAK,EAC9B,MAAM,YAAY,KAAK,CACxB,CACF;CAED,MAAM,UAAU,qBAA0C;EACxD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GAEzB,aAAa,EAMX,aAAa;IACX,MAAM,cAAc,QAAQ,YAAY,KAAK;IAC7C,MAAM,aAAa,IAAI,iBAAiB;IAExC,MAAM,qBAAqB,YAAY;AAKrC,WAAM,0BAA0B,YAAY,eAAe,WAAW,OAAO;AAC7E,SAAI,WAAW,OAAO,QAAS;KAO/B,MAAM,cAAc,kBAAkB,YAAY,cAAc;AAChE,SAAI,cAAc,EAAG,aAAY,WAAW;AAE5C,iBAAY,aAAa;;AAG3B,wBAAoB,CAAC,OAAO,QAAQ,QAAQ,MAAM,+BAA+B,IAAI,CAAC;AAEtF,iBAAa,WAAW,OAAO;MAElC;GACF;EACF,CAAC;AAEF,cAAa;AACX,qBAAmB;AACnB,UAAQ,SAAS;;;AAIrB,MAAa,cAAc,eAAe;CACxC,WAAW,CAAC,gBAAgB,cAAc;CAC1C,aAAa;EAAC;EAAe;EAAoB;EAAmB;CACpE,OAAO;CACR,CAAC"}
1
+ {"version":3,"file":"end-of-stream.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/end-of-stream.ts"],"sourcesContent":["/**\n * **Drive each `open → ended` transition of the MediaSource.** Calls\n * `MediaSource.endOfStream()` once the temporally last segments of every\n * active buffer actor's currently-loading track are fully appended and\n * the user has reached them — letting the browser finalize duration and\n * fire `ended` on the media element.\n *\n * Re-fires on every subsequent `open → ended → open` cycle. Per the MSE\n * spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource\n * back to `'open'`; seek-back replays and back-buffer refills that re-load\n * earlier segments take this path, so the behavior must call\n * `endOfStream()` again once the last segments are reappended. The reactor's\n * `'preconditions-unmet'` ↔ `'eos-ready'` cycle *is* the re-arm mechanism:\n * a successful `endOfStream()` flips `mediaSource.readyState` to `'ended'`,\n * which (via the local `msIsOpen` signal) exits `'eos-ready'`; the next\n * `'open'` re-evaluates preconditions and may re-enter.\n *\n * # Tracking what's in the buffer\n *\n * Each `SourceBufferActor` knows which track it's currently loading via\n * `initTrackId` (set on the most recent `append-init` message) and which\n * segments it has appended. `deriveState` iterates over the available\n * buffer actors (`[videoBufferActor, audioBufferActor].filter(Boolean)`),\n * resolves each to its track via `findTrackById`, and checks that track's\n * last segment is appended. This means audio-only / video-only / mixed\n * configurations compose uniformly — the body iterates whatever's in\n * scope. No reliance on `selectedTrackId` slots: the actor's view IS the\n * source of truth for \"what's being loaded into this buffer.\"\n *\n * **Two-fire on mid-end ABR switches**: when a quality switch occurs near\n * end-of-stream, the actor's `initTrackId` still reflects the *old* track\n * until the new init segment is appended. The reactor may fire\n * `endOfStream()` against the old track's last-segment-appended state,\n * then the new init's `appendBuffer()` re-opens the MS, which re-arms us\n * to fire again once the new track's last segment lands. Functionally\n * correct (the browser re-fires `ended` after the re-arm), at the cost of\n * one extra call. Accepted as the price of dropping `selectedTrackId`\n * dependence.\n *\n * # Buffer-actor idle gate\n *\n * Each actor must be `'idle'` (no queued or in-flight tasks) before we\n * fire. `buffer.updating === false` alone is insufficient: for chunked\n * fMP4 streaming, `updateend` fires after each chunk while the actor's\n * for-await loop synchronously enqueues the next `appendBuffer()` — so\n * the buffer flips `!updating → updating` across a microtask boundary.\n * The actor's `'updating'` state spans the entire multi-chunk append, so\n * `actor.snapshot.value === 'idle'` is the canonical \"no more pending or\n * in-flight work\" oracle.\n *\n * This bet (actor models the pipeline; `mediaSource.sourceBuffers` models\n * DOM state) is durable as long as the segment loader is the sole writer\n * of `appendBuffer()` calls. A future loop-mode that auto-fetches earlier\n * segments mid-`ended` would require a broader coordination story\n * between SourceBuffers and the MediaSource — possibly via a\n * `MediaSourceActor` — and that's where this gate would want\n * re-evaluating.\n *\n * # currentTime gate\n *\n * `currentTime` must have reached at least one active track's last\n * segment startTime. Prevents `'eos-ready'` entry when a back-buffer\n * `remove()` / `appendBuffer()` briefly re-opens the MediaSource while\n * the user is mid-stream. HLS rendition time-alignment means any active\n * track works as the reference.\n *\n * # MS readyState — local subscription\n *\n * The behavior subscribes to `mediaSource`'s readyState changes inside\n * its setup (via `onMediaSourceReadyStateChange`) and mirrors `'open'` to\n * a behavior-local `msIsOpen` signal that `deriveState` reads. No shared\n * `mediaSourceReadyState` slot dependency. Anticipates a future\n * `MediaSourceActor` whose snapshot would expose the same signal — the\n * consumer code wouldn't change.\n *\n * # `endOfStream` entry sequence\n *\n * 1. Wait for all SourceBuffers to be idle (DOM-level — defensive,\n * should already hold given the actor-idle gate in `deriveState`).\n * 2. Set `mediaSource.duration` from `getMaxBufferedEnd` to match actual\n * container timestamps (`endOfStream()` only clamps up implicitly;\n * setting it explicitly here keeps the final value deterministic).\n * 3. Call `mediaSource.endOfStream()`.\n *\n * State-exit cleanup (`controller.abort()`) cancels any in-flight wait\n * on source unload, presentation replace, or behavior destroy.\n *\n * # Coordination with `updateMediaSourceDuration`\n *\n * `updateMediaSourceDuration` writes the initial `mediaSource.duration`\n * from `presentation.duration` once per source; this behavior writes the\n * final value from the buffered end. Decision domains don't overlap.\n */\nimport type { Behavior } from '../../../core/composition/create-composition';\nimport { createMachineReactor } from '../../../core/reactors/create-machine-reactor';\nimport { effect } from '../../../core/signals/effect';\nimport { computed, type ReadonlySignal, signal } from '../../../core/signals/primitives';\nimport { getMaxBufferedEnd, waitForSourceBuffersReady } from '../../../media/dom/mse/duration';\nimport { isLastSegmentAppended } from '../../../media/dom/mse/end-of-stream';\nimport { onMediaSourceReadyStateChange } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation } from '../../../media/types';\nimport { isResolvedTrack } from '../../../media/types';\nimport { findTrackById } from '../../../media/utils/tracks';\nimport type { SourceBufferActor } from '../../actors/dom/source-buffer';\n\nexport interface EndOfStreamState {\n presentation?: MaybeResolvedPresentation;\n currentTime?: number;\n}\n\nexport interface EndOfStreamContext {\n mediaSource?: MediaSource;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\ntype EndOfStreamFsmState = 'preconditions-unmet' | 'eos-ready';\n\nfunction deriveState(\n presentation: MaybeResolvedPresentation | undefined,\n mediaSource: MediaSource | undefined,\n msIsOpen: boolean,\n videoBufferActor: SourceBufferActor | undefined,\n audioBufferActor: SourceBufferActor | undefined,\n currentTime: number | undefined\n): EndOfStreamFsmState {\n if (!mediaSource || !presentation || !msIsOpen) return 'preconditions-unmet';\n\n const actors = [videoBufferActor, audioBufferActor].filter((a): a is SourceBufferActor => a !== undefined);\n // No active buffer actors means setup hasn't completed wiring yet.\n if (actors.length === 0) return 'preconditions-unmet';\n\n // Track the latest last-segment startTime across actors as the\n // currentTime reference. HLS rendition time-alignment makes any active\n // track usable; using the max is safest when timings drift slightly.\n let lastSegStart: number | undefined;\n\n for (const actor of actors) {\n const snapshot = actor.snapshot.get();\n if (snapshot.value !== 'idle') return 'preconditions-unmet';\n\n const { initTrackId, segments: appended } = snapshot.context;\n if (!initTrackId) return 'preconditions-unmet';\n\n const track = findTrackById(presentation, initTrackId);\n if (!track || !isResolvedTrack(track)) return 'preconditions-unmet';\n if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet';\n\n if (track.segments.length > 0) {\n const start = track.segments[track.segments.length - 1]!.startTime;\n if (lastSegStart === undefined || start > lastSegStart) lastSegStart = start;\n }\n }\n\n if (lastSegStart !== undefined && (currentTime ?? 0) < lastSegStart) {\n return 'preconditions-unmet';\n }\n\n return 'eos-ready';\n}\n\nfunction endOfStreamSetup({\n state,\n context,\n}: {\n state: {\n presentation: ReadonlySignal<EndOfStreamState['presentation']>;\n currentTime: ReadonlySignal<EndOfStreamState['currentTime']>;\n };\n context: {\n mediaSource: ReadonlySignal<EndOfStreamContext['mediaSource']>;\n // See behavior definition for details on these optional context signals.\n videoBufferActor?: ReadonlySignal<SourceBufferActor | undefined>;\n audioBufferActor?: ReadonlySignal<SourceBufferActor | undefined>;\n };\n}): () => void {\n // Behavior-local mirror of `mediaSource.readyState === 'open'`. Subscribes\n // to MS events whenever a MediaSource is in scope; tears down when the\n // MS is swapped or the behavior destroys. Anticipates a future\n // `MediaSourceActor` whose snapshot would expose the same value — the\n // consumer code (deriveState) wouldn't change.\n const msIsOpen = signal(false);\n const cleanupMsListener = effect(() => {\n const mediaSource = context.mediaSource.get();\n if (!mediaSource) {\n msIsOpen.set(false);\n return;\n }\n msIsOpen.set(mediaSource.readyState === 'open');\n const controller = new AbortController();\n onMediaSourceReadyStateChange(mediaSource, controller.signal, (rs) => {\n msIsOpen.set(rs === 'open');\n });\n return () => controller.abort();\n });\n\n const derivedStateSignal = computed(() =>\n deriveState(\n state.presentation.get(),\n context.mediaSource.get(),\n msIsOpen.get(),\n context.videoBufferActor?.get(),\n context.audioBufferActor?.get(),\n state.currentTime.get()\n )\n );\n\n const reactor = createMachineReactor<EndOfStreamFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'eos-ready': {\n // entry body is auto-untracked. deriveState handles source resets\n // and the MS readyState flip from 'open' to 'ended' that\n // endOfStream() produces; this entry resolves the MSE-spec\n // preconditions and binds its cleanup (abort) to state exit +\n // destroy.\n entry: () => {\n const mediaSource = context.mediaSource.get()!;\n const controller = new AbortController();\n\n const endStreamWhenReady = async () => {\n // Defensive: the actor-idle gate in deriveState should mean\n // buffers are already settled, but a microtask boundary\n // between deriveState's read and entry firing leaves a thin\n // gap. The DOM-level wait closes it.\n await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal);\n if (controller.signal.aborted) return;\n\n // MSE spec: duration cannot be less than any buffered range, and\n // endOfStream() will only clamp up implicitly. Setting it\n // explicitly here from actual container timestamps keeps the\n // final value deterministic for assets whose declared duration\n // disagrees with the buffered end (common with CMAF).\n const bufferedEnd = getMaxBufferedEnd(mediaSource.sourceBuffers);\n if (bufferedEnd > 0) mediaSource.duration = bufferedEnd;\n\n mediaSource.endOfStream();\n };\n\n endStreamWhenReady().catch((err) => console.error('Failed to call endOfStream:', err));\n\n return () => controller.abort();\n },\n },\n },\n });\n\n return () => {\n cleanupMsListener();\n reactor.destroy();\n };\n}\n\n/**\n * `endOfStream` uses a manual `Behavior<>` literal (rather than\n * `defineBehavior`) because it reads `videoBufferActor` /\n * `audioBufferActor` defensively without declaring them in its\n * contextKeys — those slots are contributed by other behaviors and\n * compose conditionally per engine variant. The `Behavior<>` literal\n * opts out of the exhaustiveness check so the typed context shape can\n * include the optional fields used at runtime. See the comment on\n * `endOfStreamSetup`'s context param for the discipline.\n */\nexport const endOfStream: Behavior<\n {\n presentation: ReadonlySignal<EndOfStreamState['presentation']>;\n currentTime: ReadonlySignal<EndOfStreamState['currentTime']>;\n },\n { mediaSource: ReadonlySignal<EndOfStreamContext['mediaSource']> }\n> = {\n stateKeys: ['presentation', 'currentTime'],\n contextKeys: ['mediaSource'],\n setup: endOfStreamSetup,\n};\n"],"mappings":";;;;;;;;;AAsHA,SAAS,YACP,cACA,aACA,UACA,kBACA,kBACA,aACqB;AACrB,KAAI,CAAC,eAAe,CAAC,gBAAgB,CAAC,SAAU,QAAO;CAEvD,MAAM,SAAS,CAAC,kBAAkB,iBAAiB,CAAC,QAAQ,MAA8B,MAAM,KAAA,EAAU;AAE1G,KAAI,OAAO,WAAW,EAAG,QAAO;CAKhC,IAAI;AAEJ,MAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,WAAW,MAAM,SAAS,KAAK;AACrC,MAAI,SAAS,UAAU,OAAQ,QAAO;EAEtC,MAAM,EAAE,aAAa,UAAU,aAAa,SAAS;AACrD,MAAI,CAAC,YAAa,QAAO;EAEzB,MAAM,QAAQ,cAAc,cAAc,YAAY;AACtD,MAAI,CAAC,SAAS,CAAC,gBAAgB,MAAM,CAAE,QAAO;AAC9C,MAAI,CAAC,sBAAsB,MAAM,UAAU,SAAS,CAAE,QAAO;AAE7D,MAAI,MAAM,SAAS,SAAS,GAAG;GAC7B,MAAM,QAAQ,MAAM,SAAS,MAAM,SAAS,SAAS,GAAI;AACzD,OAAI,iBAAiB,KAAA,KAAa,QAAQ,aAAc,gBAAe;;;AAI3E,KAAI,iBAAiB,KAAA,MAAc,eAAe,KAAK,aACrD,QAAO;AAGT,QAAO;;AAGT,SAAS,iBAAiB,EACxB,OACA,WAYa;CAMb,MAAM,WAAW,OAAO,MAAM;CAC9B,MAAM,oBAAoB,aAAa;EACrC,MAAM,cAAc,QAAQ,YAAY,KAAK;AAC7C,MAAI,CAAC,aAAa;AAChB,YAAS,IAAI,MAAM;AACnB;;AAEF,WAAS,IAAI,YAAY,eAAe,OAAO;EAC/C,MAAM,aAAa,IAAI,iBAAiB;AACxC,gCAA8B,aAAa,WAAW,SAAS,OAAO;AACpE,YAAS,IAAI,OAAO,OAAO;IAC3B;AACF,eAAa,WAAW,OAAO;GAC/B;CAEF,MAAM,qBAAqB,eACzB,YACE,MAAM,aAAa,KAAK,EACxB,QAAQ,YAAY,KAAK,EACzB,SAAS,KAAK,EACd,QAAQ,kBAAkB,KAAK,EAC/B,QAAQ,kBAAkB,KAAK,EAC/B,MAAM,YAAY,KAAK,CACxB,CACF;CAED,MAAM,UAAU,qBAA0C;EACxD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GAEzB,aAAa,EAMX,aAAa;IACX,MAAM,cAAc,QAAQ,YAAY,KAAK;IAC7C,MAAM,aAAa,IAAI,iBAAiB;IAExC,MAAM,qBAAqB,YAAY;AAKrC,WAAM,0BAA0B,YAAY,eAAe,WAAW,OAAO;AAC7E,SAAI,WAAW,OAAO,QAAS;KAO/B,MAAM,cAAc,kBAAkB,YAAY,cAAc;AAChE,SAAI,cAAc,EAAG,aAAY,WAAW;AAE5C,iBAAY,aAAa;;AAG3B,wBAAoB,CAAC,OAAO,QAAQ,QAAQ,MAAM,+BAA+B,IAAI,CAAC;AAEtF,iBAAa,WAAW,OAAO;MAElC;GACF;EACF,CAAC;AAEF,cAAa;AACX,qBAAmB;AACnB,UAAQ,SAAS;;;;;;;;;;;;;AAcrB,MAAa,cAMT;CACF,WAAW,CAAC,gBAAgB,cAAc;CAC1C,aAAa,CAAC,cAAc;CAC5B,OAAO;CACR"}
@@ -3,7 +3,7 @@ import { defineBehavior } from "../../../core/composition/create-composition.js"
3
3
  import { createMachineReactor } from "../../../core/reactors/create-machine-reactor.js";
4
4
  import { DEFAULT_FORWARD_BUFFER_CONFIG, segmentStartForTime } from "../../../media/buffer/forward-buffer.js";
5
5
  import { findResolvedAudioTrack, findResolvedTextTrack, findResolvedVideoTrack } from "../../../media/utils/tracks.js";
6
- import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from "../track-types.js";
6
+ import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from "../../primitives/track-types.js";
7
7
  //#region src/playback/behaviors/dom/load-segments.ts
8
8
  /**
9
9
  * **Per-type segment loading dispatch.** Per available track type (video /
@@ -1 +1 @@
1
- {"version":3,"file":"load-segments.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/load-segments.ts"],"sourcesContent":["/**\n * **Per-type segment loading dispatch.** Per available track type (video /\n * audio / text), reads the per-type segment-loader actor from context and\n * sends typed `'load'` messages whenever a meaningful loading condition\n * changes (selected track, current time crossing a segment boundary).\n *\n * Loader-actor lifecycle is owned upstream:\n * - Video: `setupVideoBufferActors`\n * - Audio: `setupAudioBufferActors`\n * - Text: `setupTextTrackActors`\n *\n * This behavior is pure-consumer: it reads `context[loaderKey]` and\n * dispatches typed messages via the variant's per-type `findResolvedTrack`\n * resolver.\n *\n * # Load modes as reactor states\n *\n * Four states encode the load-gating policy directly:\n *\n * - `'preconditions-unmet'` — no loader actor in context, or the selected\n * track hasn't resolved.\n * - `'dormant'` — `preload === 'none' && !loadActivated`. Nothing fires.\n * - `'metadata-only'` — `!loadActivated && preload !== 'auto' && preload !== 'none'`.\n * Fires an init-segment-only `load` message **once on entry**. The\n * variant's loader actor decides what to do — v/a's actor fetches the\n * init segment; text's actor no-ops (no init concept).\n * - `'full-range'` — `loadActivated || preload === 'auto'`. Effect re-fires\n * on selected-track change and on segment-boundary crossing.\n *\n * # Per-type parameterization (inference-driven)\n *\n * The helper is generic over `Track` — the resolved-track type. Each\n * variant supplies its own `findResolvedTrack` resolver via config; TS\n * infers `Track` from the resolver's return type. The loader signal's\n * value type is constrained to `SegmentLoaderLike<Track>` — anything with\n * a `send` method accepting the `Track`-parameterized message. Concrete\n * actor types (`SegmentLoaderActor`, `TextTrackSegmentLoaderActor`)\n * satisfy this via function-parameter contravariance: an actor whose\n * `.send` accepts a wider track type is assignable to a slot expecting a\n * narrower track type.\n *\n * No widening of actor message types is needed; no casts inside the\n * helper. Per-variant wiring (right loader paired with right resolver)\n * is enforced at the variant call site.\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, peek, type ReadonlySignal } from '../../../core/signals/primitives';\nimport {\n DEFAULT_FORWARD_BUFFER_CONFIG,\n type ForwardBufferConfig,\n segmentStartForTime,\n} from '../../../media/buffer/forward-buffer';\nimport type { MaybeResolvedPresentation, Segment } from '../../../media/types';\nimport { findResolvedAudioTrack, findResolvedTextTrack, findResolvedVideoTrack } from '../../../media/utils/tracks';\nimport type { BufferState, SegmentLoaderActor, SourceBufferState } from '../../actors/dom/segment-loader';\nimport type { TextTrackSegmentLoaderActor } from '../../actors/text-track-segment-loader';\nimport { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../track-types';\n\n// Re-export buffer state types for consumers that import them from this module.\nexport type { BufferState, SourceBufferState };\n\n// ============================================================================\n// STATE & CONTEXT\n// ============================================================================\n\n/** State shape for segment loading. */\nexport interface SegmentLoadingState {\n presentation?: MaybeResolvedPresentation;\n preload?: string;\n /** Current playback position in seconds. Defaults to 0 when undefined. */\n currentTime?: number;\n /** True once a preload-overriding event has fired for the current source. */\n loadActivated?: boolean;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/** Context shape for segment loading. Each variant only consumes its own type's loader actor. */\nexport interface SegmentLoadingContext {\n videoSegmentLoaderActor?: SegmentLoaderActor;\n audioSegmentLoaderActor?: SegmentLoaderActor;\n textTrackSegmentLoaderActor?: TextTrackSegmentLoaderActor;\n}\n\n// ============================================================================\n// REACTOR\n// ============================================================================\n\ntype SegmentLoadingFsmState = 'preconditions-unmet' | 'dormant' | 'metadata-only' | 'full-range';\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';\ntype SegmentLoaderActorKey = 'videoSegmentLoaderActor' | 'audioSegmentLoaderActor' | 'textTrackSegmentLoaderActor';\n\ntype SegmentLoadingStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<SegmentLoadingState['presentation']>;\n preload: ReadonlySignal<SegmentLoadingState['preload']>;\n currentTime: ReadonlySignal<SegmentLoadingState['currentTime']>;\n loadActivated: ReadonlySignal<SegmentLoadingState['loadActivated']>;\n} & { [P in K]: ReadonlySignal<SegmentLoadingState[P]> };\n\n/** Shared `'load'` message shape parameterized over the resolved track type. */\ninterface LoadMessage<Track> {\n type: 'load';\n track: Track;\n range?: { start: number; end: number };\n}\n\n/** Structural constraint for any segment-loader-style actor. */\ninterface SegmentLoaderLike<Track> {\n send: (message: LoadMessage<Track>) => void;\n}\n\n/**\n * Specialization helper. Generic over `Track` (inferred from\n * `findResolvedTrack`'s return type). The loader value type is\n * constrained to `SegmentLoaderLike<Track>` — concrete actor types\n * (whose `.send` accepts a wider track union) satisfy this via\n * function-parameter contravariance.\n *\n * Tracks that the helper handles must have a `segments` field — used by\n * `segmentBoundarySignal` to compute the load-anchor boundary. Each\n * variant's resolver narrows to the right resolved-track shape.\n */\nfunction setupSegmentLoading<\n K extends SelectedTrackKey,\n L extends SegmentLoaderActorKey,\n Track extends { segments: readonly Segment[] },\n>({\n state,\n context,\n config,\n}: {\n state: SegmentLoadingStateMap<K>;\n context: { [P in L]: ReadonlySignal<SegmentLoaderLike<Track> | undefined> };\n config: {\n selectedKey: K;\n loaderKey: L;\n findResolvedTrack: (\n presentation: MaybeResolvedPresentation | undefined,\n trackId: string | undefined\n ) => Track | undefined;\n forwardBuffer?: Partial<ForwardBufferConfig>;\n };\n}): Reactor<SegmentLoadingFsmState | 'destroying' | 'destroyed'> {\n const { selectedKey, loaderKey, findResolvedTrack } = config;\n const bufferDuration = config.forwardBuffer?.bufferDuration ?? DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration;\n\n const selectedTrack = computed<Track | undefined>(() =>\n findResolvedTrack(state.presentation.get(), state[selectedKey].get())\n );\n\n // Segment-boundary signal — `segmentStartForTime` returns the same number\n // while `currentTime` stays inside one segment, so signal-polyfill's\n // `Object.is` equality on this computed dedups within-segment ticks.\n const segmentBoundarySignal = computed(() => {\n const track = selectedTrack.get();\n if (!track) return undefined;\n return segmentStartForTime(state.currentTime.get() ?? 0, track.segments);\n });\n\n const derivedStateSignal = computed<SegmentLoadingFsmState>(() => {\n if (!context[loaderKey].get() || !selectedTrack.get()) return 'preconditions-unmet';\n if (state.loadActivated.get() || state.preload.get() === 'auto') return 'full-range';\n if (state.preload.get() === 'none') return 'dormant';\n return 'metadata-only';\n });\n\n return createMachineReactor<SegmentLoadingFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n dormant: {},\n 'metadata-only': {\n // Fires once on entry. Matches HTMLMediaElement's preload='metadata'\n // semantics — load enough to surface metadata for the entry-time\n // selection. Each variant's loader decides what to do: v/a's actor\n // fetches the init segment; text's actor treats no-range as a\n // no-op. Selection changes while still in `'metadata-only'` are\n // intentionally not followed.\n entry: () => {\n const track = selectedTrack.get()!;\n context[loaderKey].get()!.send({ type: 'load', track });\n },\n },\n 'full-range': {\n // Re-fires on selected-track changes and on segment-boundary\n // crossings. The loader and `currentTime` are peeked — the\n // boundary-dedup signal already handles re-firing policy.\n effects: () => {\n const track = selectedTrack.get()!;\n segmentBoundarySignal.get();\n const currentTime = peek(state.currentTime) ?? 0;\n peek(context[loaderKey])!.send({\n type: 'load',\n track,\n range: { start: currentTime, end: currentTime + bufferDuration },\n });\n },\n },\n },\n });\n}\n\n// ============================================================================\n// Per-helper-per-type configs — defaults that variants spread engine config over\n// ============================================================================\n\nconst VIDEO_SEGMENT_LOADING_CONFIG = {\n ...VIDEO_TYPE_CONFIG,\n findResolvedTrack: findResolvedVideoTrack,\n} as const;\n\nconst AUDIO_SEGMENT_LOADING_CONFIG = {\n ...AUDIO_TYPE_CONFIG,\n findResolvedTrack: findResolvedAudioTrack,\n} as const;\n\nconst TEXT_SEGMENT_LOADING_CONFIG = {\n ...TEXT_TYPE_CONFIG,\n findResolvedTrack: findResolvedTextTrack,\n} as const;\n\n// ============================================================================\n// Specialized exports — one per media type\n// ============================================================================\n\nexport const loadVideoSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedVideoTrackId'],\n contextKeys: ['videoSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedVideoTrackId'>;\n context: {\n videoSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['videoSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...VIDEO_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n\nexport const loadAudioSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedAudioTrackId'],\n contextKeys: ['audioSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedAudioTrackId'>;\n context: {\n audioSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['audioSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...AUDIO_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n\nexport const loadTextTrackSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedTextTrackId'],\n contextKeys: ['textTrackSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedTextTrackId'>;\n context: {\n textTrackSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['textTrackSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...TEXT_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8HA,SAAS,oBAIP,EACA,OACA,SACA,UAa+D;CAC/D,MAAM,EAAE,aAAa,WAAW,sBAAsB;CACtD,MAAM,iBAAiB,OAAO,eAAe,kBAAkB,8BAA8B;CAE7F,MAAM,gBAAgB,eACpB,kBAAkB,MAAM,aAAa,KAAK,EAAE,MAAM,aAAa,KAAK,CAAC,CACtE;CAKD,MAAM,wBAAwB,eAAe;EAC3C,MAAM,QAAQ,cAAc,KAAK;AACjC,MAAI,CAAC,MAAO,QAAO,KAAA;AACnB,SAAO,oBAAoB,MAAM,YAAY,KAAK,IAAI,GAAG,MAAM,SAAS;GACxE;CAEF,MAAM,qBAAqB,eAAuC;AAChE,MAAI,CAAC,QAAQ,WAAW,KAAK,IAAI,CAAC,cAAc,KAAK,CAAE,QAAO;AAC9D,MAAI,MAAM,cAAc,KAAK,IAAI,MAAM,QAAQ,KAAK,KAAK,OAAQ,QAAO;AACxE,MAAI,MAAM,QAAQ,KAAK,KAAK,OAAQ,QAAO;AAC3C,SAAO;GACP;AAEF,QAAO,qBAA6C;EAClD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GACzB,SAAS,EAAE;GACX,iBAAiB,EAOf,aAAa;IACX,MAAM,QAAQ,cAAc,KAAK;AACjC,YAAQ,WAAW,KAAK,CAAE,KAAK;KAAE,MAAM;KAAQ;KAAO,CAAC;MAE1D;GACD,cAAc,EAIZ,eAAe;IACb,MAAM,QAAQ,cAAc,KAAK;AACjC,0BAAsB,KAAK;IAC3B,MAAM,cAAc,KAAK,MAAM,YAAY,IAAI;AAC/C,SAAK,QAAQ,WAAW,CAAE,KAAK;KAC7B,MAAM;KACN;KACA,OAAO;MAAE,OAAO;MAAa,KAAK,cAAc;MAAgB;KACjE,CAAC;MAEL;GACF;EACF,CAAC;;AAOJ,MAAM,+BAA+B;CACnC,GAAG;CACH,mBAAmB;CACpB;AAED,MAAM,+BAA+B;CACnC,GAAG;CACH,mBAAmB;CACpB;AAED,MAAM,8BAA8B;CAClC,GAAG;CACH,mBAAmB;CACpB;AAMD,MAAa,oBAAoB,eAAe;CAC9C,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAuB;CAC9F,aAAa,CAAC,0BAA0B;CACxC,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA8B,GAAG;GAAQ;EACvD,CAAC;CACL,CAAC;AAEF,MAAa,oBAAoB,eAAe;CAC9C,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAuB;CAC9F,aAAa,CAAC,0BAA0B;CACxC,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA8B,GAAG;GAAQ;EACvD,CAAC;CACL,CAAC;AAEF,MAAa,wBAAwB,eAAe;CAClD,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAsB;CAC7F,aAAa,CAAC,8BAA8B;CAC5C,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA6B,GAAG;GAAQ;EACtD,CAAC;CACL,CAAC"}
1
+ {"version":3,"file":"load-segments.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/load-segments.ts"],"sourcesContent":["/**\n * **Per-type segment loading dispatch.** Per available track type (video /\n * audio / text), reads the per-type segment-loader actor from context and\n * sends typed `'load'` messages whenever a meaningful loading condition\n * changes (selected track, current time crossing a segment boundary).\n *\n * Loader-actor lifecycle is owned upstream:\n * - Video: `setupVideoBufferActors`\n * - Audio: `setupAudioBufferActors`\n * - Text: `setupTextTrackActors`\n *\n * This behavior is pure-consumer: it reads `context[loaderKey]` and\n * dispatches typed messages via the variant's per-type `findResolvedTrack`\n * resolver.\n *\n * # Load modes as reactor states\n *\n * Four states encode the load-gating policy directly:\n *\n * - `'preconditions-unmet'` — no loader actor in context, or the selected\n * track hasn't resolved.\n * - `'dormant'` — `preload === 'none' && !loadActivated`. Nothing fires.\n * - `'metadata-only'` — `!loadActivated && preload !== 'auto' && preload !== 'none'`.\n * Fires an init-segment-only `load` message **once on entry**. The\n * variant's loader actor decides what to do — v/a's actor fetches the\n * init segment; text's actor no-ops (no init concept).\n * - `'full-range'` — `loadActivated || preload === 'auto'`. Effect re-fires\n * on selected-track change and on segment-boundary crossing.\n *\n * # Per-type parameterization (inference-driven)\n *\n * The helper is generic over `Track` — the resolved-track type. Each\n * variant supplies its own `findResolvedTrack` resolver via config; TS\n * infers `Track` from the resolver's return type. The loader signal's\n * value type is constrained to `SegmentLoaderLike<Track>` — anything with\n * a `send` method accepting the `Track`-parameterized message. Concrete\n * actor types (`SegmentLoaderActor`, `TextTrackSegmentLoaderActor`)\n * satisfy this via function-parameter contravariance: an actor whose\n * `.send` accepts a wider track type is assignable to a slot expecting a\n * narrower track type.\n *\n * No widening of actor message types is needed; no casts inside the\n * helper. Per-variant wiring (right loader paired with right resolver)\n * is enforced at the variant call site.\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, peek, type ReadonlySignal } from '../../../core/signals/primitives';\nimport {\n DEFAULT_FORWARD_BUFFER_CONFIG,\n type ForwardBufferConfig,\n segmentStartForTime,\n} from '../../../media/buffer/forward-buffer';\nimport type { MaybeResolvedPresentation, Segment } from '../../../media/types';\nimport { findResolvedAudioTrack, findResolvedTextTrack, findResolvedVideoTrack } from '../../../media/utils/tracks';\nimport type { BufferState, SegmentLoaderActor, SourceBufferState } from '../../actors/dom/segment-loader';\nimport type { TextTrackSegmentLoaderActor } from '../../actors/text-track-segment-loader';\nimport { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../../primitives/track-types';\n\n// Re-export buffer state types for consumers that import them from this module.\nexport type { BufferState, SourceBufferState };\n\n// ============================================================================\n// STATE & CONTEXT\n// ============================================================================\n\n/** State shape for segment loading. */\nexport interface SegmentLoadingState {\n presentation?: MaybeResolvedPresentation;\n preload?: string;\n /** Current playback position in seconds. Defaults to 0 when undefined. */\n currentTime?: number;\n /** True once a preload-overriding event has fired for the current source. */\n loadActivated?: boolean;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/** Context shape for segment loading. Each variant only consumes its own type's loader actor. */\nexport interface SegmentLoadingContext {\n videoSegmentLoaderActor?: SegmentLoaderActor;\n audioSegmentLoaderActor?: SegmentLoaderActor;\n textTrackSegmentLoaderActor?: TextTrackSegmentLoaderActor;\n}\n\n// ============================================================================\n// REACTOR\n// ============================================================================\n\ntype SegmentLoadingFsmState = 'preconditions-unmet' | 'dormant' | 'metadata-only' | 'full-range';\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';\ntype SegmentLoaderActorKey = 'videoSegmentLoaderActor' | 'audioSegmentLoaderActor' | 'textTrackSegmentLoaderActor';\n\ntype SegmentLoadingStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<SegmentLoadingState['presentation']>;\n preload: ReadonlySignal<SegmentLoadingState['preload']>;\n currentTime: ReadonlySignal<SegmentLoadingState['currentTime']>;\n loadActivated: ReadonlySignal<SegmentLoadingState['loadActivated']>;\n} & { [P in K]: ReadonlySignal<SegmentLoadingState[P]> };\n\n/** Shared `'load'` message shape parameterized over the resolved track type. */\ninterface LoadMessage<Track> {\n type: 'load';\n track: Track;\n range?: { start: number; end: number };\n}\n\n/** Structural constraint for any segment-loader-style actor. */\ninterface SegmentLoaderLike<Track> {\n send: (message: LoadMessage<Track>) => void;\n}\n\n/**\n * Specialization helper. Generic over `Track` (inferred from\n * `findResolvedTrack`'s return type). The loader value type is\n * constrained to `SegmentLoaderLike<Track>` — concrete actor types\n * (whose `.send` accepts a wider track union) satisfy this via\n * function-parameter contravariance.\n *\n * Tracks that the helper handles must have a `segments` field — used by\n * `segmentBoundarySignal` to compute the load-anchor boundary. Each\n * variant's resolver narrows to the right resolved-track shape.\n */\nfunction setupSegmentLoading<\n K extends SelectedTrackKey,\n L extends SegmentLoaderActorKey,\n Track extends { segments: readonly Segment[] },\n>({\n state,\n context,\n config,\n}: {\n state: SegmentLoadingStateMap<K>;\n context: { [P in L]: ReadonlySignal<SegmentLoaderLike<Track> | undefined> };\n config: {\n selectedKey: K;\n loaderKey: L;\n findResolvedTrack: (\n presentation: MaybeResolvedPresentation | undefined,\n trackId: string | undefined\n ) => Track | undefined;\n forwardBuffer?: Partial<ForwardBufferConfig>;\n };\n}): Reactor<SegmentLoadingFsmState | 'destroying' | 'destroyed'> {\n const { selectedKey, loaderKey, findResolvedTrack } = config;\n const bufferDuration = config.forwardBuffer?.bufferDuration ?? DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration;\n\n const selectedTrack = computed<Track | undefined>(() =>\n findResolvedTrack(state.presentation.get(), state[selectedKey].get())\n );\n\n // Segment-boundary signal — `segmentStartForTime` returns the same number\n // while `currentTime` stays inside one segment, so signal-polyfill's\n // `Object.is` equality on this computed dedups within-segment ticks.\n const segmentBoundarySignal = computed(() => {\n const track = selectedTrack.get();\n if (!track) return undefined;\n return segmentStartForTime(state.currentTime.get() ?? 0, track.segments);\n });\n\n const derivedStateSignal = computed<SegmentLoadingFsmState>(() => {\n if (!context[loaderKey].get() || !selectedTrack.get()) return 'preconditions-unmet';\n if (state.loadActivated.get() || state.preload.get() === 'auto') return 'full-range';\n if (state.preload.get() === 'none') return 'dormant';\n return 'metadata-only';\n });\n\n return createMachineReactor<SegmentLoadingFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n dormant: {},\n 'metadata-only': {\n // Fires once on entry. Matches HTMLMediaElement's preload='metadata'\n // semantics — load enough to surface metadata for the entry-time\n // selection. Each variant's loader decides what to do: v/a's actor\n // fetches the init segment; text's actor treats no-range as a\n // no-op. Selection changes while still in `'metadata-only'` are\n // intentionally not followed.\n entry: () => {\n const track = selectedTrack.get()!;\n context[loaderKey].get()!.send({ type: 'load', track });\n },\n },\n 'full-range': {\n // Re-fires on selected-track changes and on segment-boundary\n // crossings. The loader and `currentTime` are peeked — the\n // boundary-dedup signal already handles re-firing policy.\n effects: () => {\n const track = selectedTrack.get()!;\n segmentBoundarySignal.get();\n const currentTime = peek(state.currentTime) ?? 0;\n peek(context[loaderKey])!.send({\n type: 'load',\n track,\n range: { start: currentTime, end: currentTime + bufferDuration },\n });\n },\n },\n },\n });\n}\n\n// ============================================================================\n// Per-helper-per-type configs — defaults that variants spread engine config over\n// ============================================================================\n\nconst VIDEO_SEGMENT_LOADING_CONFIG = {\n ...VIDEO_TYPE_CONFIG,\n findResolvedTrack: findResolvedVideoTrack,\n} as const;\n\nconst AUDIO_SEGMENT_LOADING_CONFIG = {\n ...AUDIO_TYPE_CONFIG,\n findResolvedTrack: findResolvedAudioTrack,\n} as const;\n\nconst TEXT_SEGMENT_LOADING_CONFIG = {\n ...TEXT_TYPE_CONFIG,\n findResolvedTrack: findResolvedTextTrack,\n} as const;\n\n// ============================================================================\n// Specialized exports — one per media type\n// ============================================================================\n\nexport const loadVideoSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedVideoTrackId'],\n contextKeys: ['videoSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedVideoTrackId'>;\n context: {\n videoSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['videoSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...VIDEO_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n\nexport const loadAudioSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedAudioTrackId'],\n contextKeys: ['audioSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedAudioTrackId'>;\n context: {\n audioSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['audioSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...AUDIO_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n\nexport const loadTextTrackSegments = defineBehavior({\n stateKeys: ['presentation', 'preload', 'currentTime', 'loadActivated', 'selectedTextTrackId'],\n contextKeys: ['textTrackSegmentLoaderActor'],\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: SegmentLoadingStateMap<'selectedTextTrackId'>;\n context: {\n textTrackSegmentLoaderActor: ReadonlySignal<SegmentLoadingContext['textTrackSegmentLoaderActor']>;\n };\n config?: object;\n }) =>\n setupSegmentLoading({\n state,\n context,\n config: { ...TEXT_SEGMENT_LOADING_CONFIG, ...config },\n }),\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8HA,SAAS,oBAIP,EACA,OACA,SACA,UAa+D;CAC/D,MAAM,EAAE,aAAa,WAAW,sBAAsB;CACtD,MAAM,iBAAiB,OAAO,eAAe,kBAAkB,8BAA8B;CAE7F,MAAM,gBAAgB,eACpB,kBAAkB,MAAM,aAAa,KAAK,EAAE,MAAM,aAAa,KAAK,CAAC,CACtE;CAKD,MAAM,wBAAwB,eAAe;EAC3C,MAAM,QAAQ,cAAc,KAAK;AACjC,MAAI,CAAC,MAAO,QAAO,KAAA;AACnB,SAAO,oBAAoB,MAAM,YAAY,KAAK,IAAI,GAAG,MAAM,SAAS;GACxE;CAEF,MAAM,qBAAqB,eAAuC;AAChE,MAAI,CAAC,QAAQ,WAAW,KAAK,IAAI,CAAC,cAAc,KAAK,CAAE,QAAO;AAC9D,MAAI,MAAM,cAAc,KAAK,IAAI,MAAM,QAAQ,KAAK,KAAK,OAAQ,QAAO;AACxE,MAAI,MAAM,QAAQ,KAAK,KAAK,OAAQ,QAAO;AAC3C,SAAO;GACP;AAEF,QAAO,qBAA6C;EAClD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GACzB,SAAS,EAAE;GACX,iBAAiB,EAOf,aAAa;IACX,MAAM,QAAQ,cAAc,KAAK;AACjC,YAAQ,WAAW,KAAK,CAAE,KAAK;KAAE,MAAM;KAAQ;KAAO,CAAC;MAE1D;GACD,cAAc,EAIZ,eAAe;IACb,MAAM,QAAQ,cAAc,KAAK;AACjC,0BAAsB,KAAK;IAC3B,MAAM,cAAc,KAAK,MAAM,YAAY,IAAI;AAC/C,SAAK,QAAQ,WAAW,CAAE,KAAK;KAC7B,MAAM;KACN;KACA,OAAO;MAAE,OAAO;MAAa,KAAK,cAAc;MAAgB;KACjE,CAAC;MAEL;GACF;EACF,CAAC;;AAOJ,MAAM,+BAA+B;CACnC,GAAG;CACH,mBAAmB;CACpB;AAED,MAAM,+BAA+B;CACnC,GAAG;CACH,mBAAmB;CACpB;AAED,MAAM,8BAA8B;CAClC,GAAG;CACH,mBAAmB;CACpB;AAMD,MAAa,oBAAoB,eAAe;CAC9C,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAuB;CAC9F,aAAa,CAAC,0BAA0B;CACxC,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA8B,GAAG;GAAQ;EACvD,CAAC;CACL,CAAC;AAEF,MAAa,oBAAoB,eAAe;CAC9C,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAuB;CAC9F,aAAa,CAAC,0BAA0B;CACxC,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA8B,GAAG;GAAQ;EACvD,CAAC;CACL,CAAC;AAEF,MAAa,wBAAwB,eAAe;CAClD,WAAW;EAAC;EAAgB;EAAW;EAAe;EAAiB;EAAsB;CAC7F,aAAa,CAAC,8BAA8B;CAC5C,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAQX,oBAAoB;EAClB;EACA;EACA,QAAQ;GAAE,GAAG;GAA6B,GAAG;GAAQ;EACtD,CAAC;CACL,CAAC"}
@@ -2,12 +2,13 @@ import { computed } from "../../../core/signals/primitives.js";
2
2
  import { defineBehavior } from "../../../core/composition/create-composition.js";
3
3
  import { createMachineReactor } from "../../../core/reactors/create-machine-reactor.js";
4
4
  import { hasCodecs } from "../../../media/utils/tracks.js";
5
- import { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from "../track-types.js";
6
- import { getSelectedTrack } from "../../../media/utils/track-selection.js";
5
+ import { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from "../../primitives/track-types.js";
7
6
  import { buildMimeCodec, createSourceBuffer } from "../../../media/dom/mse/mediasource-setup.js";
7
+ import { getSelectedTrack } from "../../../media/utils/track-selection.js";
8
8
  import { createTrackedFetch, fetchStream } from "../../../network/fetch.js";
9
9
  import { createSegmentLoaderActor } from "../../actors/dom/segment-loader.js";
10
10
  import { createSourceBufferActor } from "../../actors/dom/source-buffer.js";
11
+ import { failoverFetch } from "../../primitives/failover-fetch.js";
11
12
  //#region src/playback/behaviors/dom/setup-buffer-actors.ts
12
13
  /**
13
14
  * **Per-type buffer + segment-loader actor setup.** Per available track
@@ -39,7 +40,7 @@ import { createSourceBufferActor } from "../../actors/dom/source-buffer.js";
39
40
  * with its upstream and downstream siblings:
40
41
  *
41
42
  * 1. **Upstream — default selections land in one `runPending`.**
42
- * `selectAudioTrack` (default audio) and `switchVideoQuality`
43
+ * `selectAudioTrack` (default audio) and `switchVideoTrack`
43
44
  * (default video) both subscribe to `state.presentation` flipping to
44
45
  * resolved; their effects run in the same `runPending` iteration and
45
46
  * write `selectedAudioTrackId` + `selectedVideoTrackId` within it.
@@ -131,13 +132,16 @@ const setupVideoBufferActors = defineBehavior({
131
132
  slowTotalWeight: 0,
132
133
  bytesSampled: 0
133
134
  }, (next) => state.bandwidthState.set(next));
135
+ const typeConfig = {
136
+ ...VIDEO_TYPE_CONFIG,
137
+ ...config
138
+ };
134
139
  return setupBufferActors({
135
140
  state,
136
141
  context,
137
142
  config: {
138
- ...VIDEO_TYPE_CONFIG,
139
- fetch: trackedFetch,
140
- ...config
143
+ ...typeConfig,
144
+ fetch: failoverFetch(trackedFetch, state, typeConfig)
141
145
  }
142
146
  });
143
147
  }
@@ -148,7 +152,17 @@ const setupVideoBufferActors = defineBehavior({
148
152
  * a non-sampling `fetchStream` (no audio ABR); adding audio ABR is a
149
153
  * localized change to this setup body (swap `fetchStream` for a
150
154
  * `createTrackedFetch` call + declare `bandwidthState` writable here)
151
- * without touching the shared helper.
155
+ * without touching the shared helper. See
156
+ * `internal/design/spf/features/audio-abr.md` for the design surface
157
+ * (bandwidth-state sharing, multi-writer coordination, EWMA mixed-
158
+ * source sampling).
159
+ *
160
+ * **Mid-stream audio track switching is NOT this behavior's concern.**
161
+ * Slot writes to `selectedAudioTrackId` (default selection, programmatic
162
+ * filter-driven, future ABR) are owned by `switchAudioTrack` / future
163
+ * `switchAudioQuality` in `track-switching.ts`. Flush orchestration on
164
+ * track change is dispatched from there via `audioBufferActor.send(...)`
165
+ * — keeping this setup behavior focused on per-source actor lifecycle.
152
166
  */
153
167
  const setupAudioBufferActors = defineBehavior({
154
168
  stateKeys: ["presentation", "selectedAudioTrackId"],
@@ -157,15 +171,20 @@ const setupAudioBufferActors = defineBehavior({
157
171
  "audioBufferActor",
158
172
  "audioSegmentLoaderActor"
159
173
  ],
160
- setup: ({ state, context, config = {} }) => setupBufferActors({
161
- state,
162
- context,
163
- config: {
174
+ setup: ({ state, context, config = {} }) => {
175
+ const typeConfig = {
164
176
  ...AUDIO_TYPE_CONFIG,
165
- fetch: fetchStream,
166
177
  ...config
167
- }
168
- })
178
+ };
179
+ return setupBufferActors({
180
+ state,
181
+ context,
182
+ config: {
183
+ ...typeConfig,
184
+ fetch: failoverFetch(fetchStream, state, typeConfig)
185
+ }
186
+ });
187
+ }
169
188
  });
170
189
  //#endregion
171
190
  export { setupAudioBufferActors, setupVideoBufferActors };
@@ -1 +1 @@
1
- {"version":3,"file":"setup-buffer-actors.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/setup-buffer-actors.ts"],"sourcesContent":["/**\n * **Per-type buffer + segment-loader actor setup.** Per available track\n * type (video / audio), when `mediaSource` is attached and the selected\n * track of that type is present in the presentation with codecs (partial\n * resolution from the multivariant playlist is enough — codecs live on\n * the `EXT-X-STREAM-INF` line, not in the per-type media playlist),\n * creates a `SourceBuffer`, a `SourceBufferActor`, and a\n * `SegmentLoaderActor` bound to that buffer-actor; publishes the per-type\n * actor slots. On `mediaSource` detach or behavior destroy, destroys both\n * actors in reverse order and clears the per-type slots so the next\n * source starts fresh.\n *\n * Each per-type variant (`setupVideoBufferActors` /\n * `setupAudioBufferActors`) is a single-positive-state reactor\n * (`'preconditions-unmet'` ↔ `'buffer-ready'`) gating only on its own\n * type. No cross-type coupling in `stateKeys` —\n * `setupVideoBufferActors` carries only `selectedVideoTrackId` (plus\n * `bandwidthState`, written by its trackedFetch), and audio mirrors.\n *\n * # Firefox `mozHasAudio` invariant\n *\n * Appending to a video `SourceBuffer` before the audio `SourceBuffer`\n * exists causes `mozHasAudio` to be permanently false in Firefox. With\n * the two per-type variants decoupled, the invariant is no longer\n * structural to a single `entry` body (as it was when both buffers were\n * created in one synchronous block inside a merged behavior). It's now\n * preserved by a chain of assumptions about how this behavior composes\n * with its upstream and downstream siblings:\n *\n * 1. **Upstream — default selections land in one `runPending`.**\n * `selectAudioTrack` (default audio) and `switchVideoQuality`\n * (default video) both subscribe to `state.presentation` flipping to\n * resolved; their effects run in the same `runPending` iteration and\n * write `selectedAudioTrackId` + `selectedVideoTrackId` within it.\n * 2. **Self — both per-type monitors flip in one `runPending`.**\n * After (1), both monitors re-evaluate and flip to `'buffer-ready'`\n * in the next `runPending`. Both `entry` bodies run synchronously\n * within that iteration — both `addSourceBuffer` calls land before\n * the iteration ends.\n * 3. **Downstream — `appendBuffer` is async.** `loadVideoSegments` /\n * `loadAudioSegments` read the per-type `xSegmentLoaderActor` slots;\n * their effects fire in the *next* `runPending` and the actual\n * `appendBuffer` requires a network round-trip via the\n * `SegmentLoaderActor` — many microtasks past both `addSourceBuffer`\n * calls.\n *\n * The cross-tick failure mode — a user-initiated audio track switch\n * *after* video segments have begun appending — is out of scope for\n * this behavior and would be addressed in the buffer/segment-loading\n * path via `changeType`-aware logic.\n *\n * # Sole writer\n *\n * `setupVideoBufferActors` is sole writer of `videoBufferActor` +\n * `videoSegmentLoaderActor` (and `bandwidthState` via its\n * trackedFetch); `setupAudioBufferActors` is sole writer of\n * `audioBufferActor` + `audioSegmentLoaderActor`. Both read\n * `mediaSource` from `setupMediaSource`. Downstream MSE behaviors\n * (`loadVideoSegments`, `loadAudioSegments`, `endOfStream`,\n * `updateMediaSourceDuration`) only read these slots.\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, type Signal } from '../../../core/signals/primitives';\nimport { buildMimeCodec, createSourceBuffer } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation, PartiallyResolvedTrack } from '../../../media/types';\nimport { getSelectedTrack, type TrackSelectionState } from '../../../media/utils/track-selection';\nimport { hasCodecs } from '../../../media/utils/tracks';\nimport type { BandwidthState } from '../../../network/bandwidth-estimator';\nimport { createTrackedFetch, type FetchBytes, fetchStream } from '../../../network/fetch';\nimport {\n createSegmentLoaderActor,\n type SegmentLoaderActor,\n type SegmentLoaderActorConfig,\n} from '../../actors/dom/segment-loader';\nimport { createSourceBufferActor, type SourceBufferActor } from '../../actors/dom/source-buffer';\nimport { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../track-types';\n\n/**\n * Media track type for MSE buffer setup.\n * Text tracks are excluded as they don't use MSE SourceBuffers.\n */\nexport type MediaTrackType = 'video' | 'audio';\n\nexport interface BufferActorsState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n bandwidthState?: BandwidthState;\n}\n\nexport interface BufferActorsContext {\n mediaSource?: MediaSource;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n videoSegmentLoaderActor?: SegmentLoaderActor;\n audioSegmentLoaderActor?: SegmentLoaderActor;\n}\n\ntype BufferActorsFsmState = 'preconditions-unmet' | 'buffer-ready';\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';\ntype BufferActorKey = 'videoBufferActor' | 'audioBufferActor';\ntype SegmentLoaderActorKey = 'videoSegmentLoaderActor' | 'audioSegmentLoaderActor';\n\ntype BufferActorsStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<BufferActorsState['presentation']>;\n} & { [P in K]: ReadonlySignal<BufferActorsState[P]> };\n\ntype BufferActorsContextMap<A extends BufferActorKey, L extends SegmentLoaderActorKey> = {\n mediaSource: ReadonlySignal<BufferActorsContext['mediaSource']>;\n} & { [P in A]: Signal<SourceBufferActor | undefined> } & {\n [P in L]: Signal<SegmentLoaderActor | undefined>;\n};\n\n// ============================================================================\n// Specialization helper\n//\n// `setupBufferActors` has the same shape as a Behavior `setup` function:\n// `({ state, context, config }) => reactor`. Each `setupXBufferActors`\n// export below calls it from inside its own `defineBehavior` setup,\n// passing its narrowed `state` / `context` through directly and supplying\n// the per-type `selectedKey`, `actorKey`, `loaderKey`, `type` discriminator,\n// and `fetch` inline.\n//\n// Gating is per-type only: no cross-type coupling. See the file-level\n// JSDoc for how the Firefox `mozHasAudio` invariant survives the split\n// via SPF's effect coalescing.\n// ============================================================================\n\nfunction setupBufferActors<K extends SelectedTrackKey, A extends BufferActorKey, L extends SegmentLoaderActorKey>({\n state,\n context,\n config,\n}: {\n state: BufferActorsStateMap<K>;\n context: BufferActorsContextMap<A, L>;\n config: {\n type: MediaTrackType;\n selectedKey: K;\n actorKey: A;\n loaderKey: L;\n fetch: FetchBytes;\n } & SegmentLoaderActorConfig;\n}): Reactor<BufferActorsFsmState | 'destroying' | 'destroyed'> {\n const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer } = config;\n const derivedStateSignal = computed<BufferActorsFsmState>(() => {\n if (!context.mediaSource.get()) return 'preconditions-unmet';\n const selection: TrackSelectionState = {\n presentation: state.presentation.get(),\n [selectedKey]: state[selectedKey].get(),\n };\n return hasCodecs(getSelectedTrack(selection, type)) ? 'buffer-ready' : 'preconditions-unmet';\n });\n\n return createMachineReactor<BufferActorsFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'buffer-ready': {\n entry: () => {\n const mediaSource = context.mediaSource.get()!;\n const selection: TrackSelectionState = {\n presentation: state.presentation.get(),\n [selectedKey]: state[selectedKey].get(),\n };\n const track = getSelectedTrack(selection, type) as PartiallyResolvedTrack;\n const buffer = createSourceBuffer(mediaSource, buildMimeCodec(track));\n const bufferActor = createSourceBufferActor(buffer);\n const segmentLoader = createSegmentLoaderActor(bufferActor, fetch, { forwardBuffer, backBuffer });\n\n // Synchronous slot writes — load-bearing for the Firefox\n // `mozHasAudio` invariant (see file-level JSDoc). Both per-type\n // entries' `addSourceBuffer` calls must land in the same\n // `runPending` iteration; synchronous writes keep the contiguous\n // JS frame. Downstream `loadXSegments` effects fire in the\n // *next* `runPending`.\n context[actorKey].set(bufferActor);\n context[loaderKey].set(segmentLoader);\n\n // State-exit cleanup — fires when `mediaSource` detaches, the\n // selection unsets, or the behavior is destroyed. Destroy the\n // loader before its upstream buffer-actor so any in-flight\n // `appendBuffer` is aborted before the buffer-actor's own\n // teardown.\n return () => {\n segmentLoader.destroy();\n bufferActor.destroy();\n context[loaderKey].set(undefined);\n context[actorKey].set(undefined);\n };\n },\n },\n },\n });\n}\n\n// ============================================================================\n// Specialized exports — one per media type\n// ============================================================================\n\n/**\n * Set up the video `SourceBufferActor` + `SegmentLoaderActor`. Fires\n * when `mediaSource` is attached and the selected video track is\n * present in the presentation with codecs. Gates only on video state —\n * no cross-type coupling. Owns a bandwidth-sampling `trackedFetch` and\n * is sole writer of `state.bandwidthState`.\n */\nexport const setupVideoBufferActors = defineBehavior({\n stateKeys: ['presentation', 'selectedVideoTrackId', 'bandwidthState'] as const,\n contextKeys: ['mediaSource', 'videoBufferActor', 'videoSegmentLoaderActor'] as const,\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: BufferActorsStateMap<'selectedVideoTrackId'> & {\n bandwidthState: Signal<BufferActorsState['bandwidthState']>;\n };\n context: BufferActorsContextMap<'videoBufferActor', 'videoSegmentLoaderActor'>;\n config?: object;\n }) => {\n // Bandwidth-sampling fetch. The factory accumulates EWMA state\n // internally; the callback bridges samples to engine state for ABR.\n // Created once at behavior setup time so the EWMA accumulates across\n // source resets (each per-source loader-actor receives the same\n // tracked fetch when reconstructed in `buffer-ready` entry).\n const trackedFetch = createTrackedFetch(\n state.bandwidthState.get() ?? {\n fastEstimate: 0,\n fastTotalWeight: 0,\n slowEstimate: 0,\n slowTotalWeight: 0,\n bytesSampled: 0,\n },\n (next) => state.bandwidthState.set(next)\n );\n return setupBufferActors({\n state,\n context,\n config: { ...VIDEO_TYPE_CONFIG, fetch: trackedFetch, ...config },\n });\n },\n});\n\n/**\n * Set up the audio `SourceBufferActor` + `SegmentLoaderActor`. Same\n * shape as `setupVideoBufferActors`, narrowed to audio. Today supplies\n * a non-sampling `fetchStream` (no audio ABR); adding audio ABR is a\n * localized change to this setup body (swap `fetchStream` for a\n * `createTrackedFetch` call + declare `bandwidthState` writable here)\n * without touching the shared helper.\n */\nexport const setupAudioBufferActors = defineBehavior({\n stateKeys: ['presentation', 'selectedAudioTrackId'] as const,\n contextKeys: ['mediaSource', 'audioBufferActor', 'audioSegmentLoaderActor'] as const,\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: BufferActorsStateMap<'selectedAudioTrackId'>;\n context: BufferActorsContextMap<'audioBufferActor', 'audioSegmentLoaderActor'>;\n config?: object;\n }) =>\n setupBufferActors({\n state,\n context,\n config: { ...AUDIO_TYPE_CONFIG, fetch: fetchStream, ...config },\n }),\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmIA,SAAS,kBAAyG,EAChH,OACA,SACA,UAW6D;CAC7D,MAAM,EAAE,MAAM,aAAa,UAAU,WAAW,OAAO,eAAe,eAAe;CACrF,MAAM,qBAAqB,eAAqC;AAC9D,MAAI,CAAC,QAAQ,YAAY,KAAK,CAAE,QAAO;AAKvC,SAAO,UAAU,iBAJsB;GACrC,cAAc,MAAM,aAAa,KAAK;IACrC,cAAc,MAAM,aAAa,KAAK;GACxC,EAC4C,KAAK,CAAC,GAAG,iBAAiB;GACvE;AAEF,QAAO,qBAA2C;EAChD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GAEzB,gBAAgB,EACd,aAAa;IAQX,MAAM,cAAc,wBADL,mBANK,QAAQ,YAAY,KAAK,EAME,eADjC,iBAJyB;KACrC,cAAc,MAAM,aAAa,KAAK;MACrC,cAAc,MAAM,aAAa,KAAK;KACxC,EACyC,KAAK,CACqB,CAAC,CAClB;IACnD,MAAM,gBAAgB,yBAAyB,aAAa,OAAO;KAAE;KAAe;KAAY,CAAC;AAQjG,YAAQ,UAAU,IAAI,YAAY;AAClC,YAAQ,WAAW,IAAI,cAAc;AAOrC,iBAAa;AACX,mBAAc,SAAS;AACvB,iBAAY,SAAS;AACrB,aAAQ,WAAW,IAAI,KAAA,EAAU;AACjC,aAAQ,UAAU,IAAI,KAAA,EAAU;;MAGrC;GACF;EACF,CAAC;;;;;;;;;AAcJ,MAAa,yBAAyB,eAAe;CACnD,WAAW;EAAC;EAAgB;EAAwB;EAAiB;CACrE,aAAa;EAAC;EAAe;EAAoB;EAA0B;CAC3E,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAOP;EAMJ,MAAM,eAAe,mBACnB,MAAM,eAAe,KAAK,IAAI;GAC5B,cAAc;GACd,iBAAiB;GACjB,cAAc;GACd,iBAAiB;GACjB,cAAc;GACf,GACA,SAAS,MAAM,eAAe,IAAI,KAAK,CACzC;AACD,SAAO,kBAAkB;GACvB;GACA;GACA,QAAQ;IAAE,GAAG;IAAmB,OAAO;IAAc,GAAG;IAAQ;GACjE,CAAC;;CAEL,CAAC;;;;;;;;;AAUF,MAAa,yBAAyB,eAAe;CACnD,WAAW,CAAC,gBAAgB,uBAAuB;CACnD,aAAa;EAAC;EAAe;EAAoB;EAA0B;CAC3E,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAMX,kBAAkB;EAChB;EACA;EACA,QAAQ;GAAE,GAAG;GAAmB,OAAO;GAAa,GAAG;GAAQ;EAChE,CAAC;CACL,CAAC"}
1
+ {"version":3,"file":"setup-buffer-actors.js","names":[],"sources":["../../../../../src/playback/behaviors/dom/setup-buffer-actors.ts"],"sourcesContent":["/**\n * **Per-type buffer + segment-loader actor setup.** Per available track\n * type (video / audio), when `mediaSource` is attached and the selected\n * track of that type is present in the presentation with codecs (partial\n * resolution from the multivariant playlist is enough — codecs live on\n * the `EXT-X-STREAM-INF` line, not in the per-type media playlist),\n * creates a `SourceBuffer`, a `SourceBufferActor`, and a\n * `SegmentLoaderActor` bound to that buffer-actor; publishes the per-type\n * actor slots. On `mediaSource` detach or behavior destroy, destroys both\n * actors in reverse order and clears the per-type slots so the next\n * source starts fresh.\n *\n * Each per-type variant (`setupVideoBufferActors` /\n * `setupAudioBufferActors`) is a single-positive-state reactor\n * (`'preconditions-unmet'` ↔ `'buffer-ready'`) gating only on its own\n * type. No cross-type coupling in `stateKeys` —\n * `setupVideoBufferActors` carries only `selectedVideoTrackId` (plus\n * `bandwidthState`, written by its trackedFetch), and audio mirrors.\n *\n * # Firefox `mozHasAudio` invariant\n *\n * Appending to a video `SourceBuffer` before the audio `SourceBuffer`\n * exists causes `mozHasAudio` to be permanently false in Firefox. With\n * the two per-type variants decoupled, the invariant is no longer\n * structural to a single `entry` body (as it was when both buffers were\n * created in one synchronous block inside a merged behavior). It's now\n * preserved by a chain of assumptions about how this behavior composes\n * with its upstream and downstream siblings:\n *\n * 1. **Upstream — default selections land in one `runPending`.**\n * `selectAudioTrack` (default audio) and `switchVideoTrack`\n * (default video) both subscribe to `state.presentation` flipping to\n * resolved; their effects run in the same `runPending` iteration and\n * write `selectedAudioTrackId` + `selectedVideoTrackId` within it.\n * 2. **Self — both per-type monitors flip in one `runPending`.**\n * After (1), both monitors re-evaluate and flip to `'buffer-ready'`\n * in the next `runPending`. Both `entry` bodies run synchronously\n * within that iteration — both `addSourceBuffer` calls land before\n * the iteration ends.\n * 3. **Downstream — `appendBuffer` is async.** `loadVideoSegments` /\n * `loadAudioSegments` read the per-type `xSegmentLoaderActor` slots;\n * their effects fire in the *next* `runPending` and the actual\n * `appendBuffer` requires a network round-trip via the\n * `SegmentLoaderActor` — many microtasks past both `addSourceBuffer`\n * calls.\n *\n * The cross-tick failure mode — a user-initiated audio track switch\n * *after* video segments have begun appending — is out of scope for\n * this behavior and would be addressed in the buffer/segment-loading\n * path via `changeType`-aware logic.\n *\n * # Sole writer\n *\n * `setupVideoBufferActors` is sole writer of `videoBufferActor` +\n * `videoSegmentLoaderActor` (and `bandwidthState` via its\n * trackedFetch); `setupAudioBufferActors` is sole writer of\n * `audioBufferActor` + `audioSegmentLoaderActor`. Both read\n * `mediaSource` from `setupMediaSource`. Downstream MSE behaviors\n * (`loadVideoSegments`, `loadAudioSegments`, `endOfStream`,\n * `updateMediaSourceDuration`) only read these slots.\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, type Signal } from '../../../core/signals/primitives';\nimport { buildMimeCodec, createSourceBuffer } from '../../../media/dom/mse/mediasource-setup';\nimport type { MaybeResolvedPresentation, PartiallyResolvedTrack } from '../../../media/types';\nimport type { GetCdnId } from '../../../media/utils/cdn';\nimport { getSelectedTrack, type TrackSelectionState } from '../../../media/utils/track-selection';\nimport { hasCodecs } from '../../../media/utils/tracks';\nimport type { BandwidthState } from '../../../network/bandwidth-estimator';\nimport { createTrackedFetch, type FetchBytes, fetchStream } from '../../../network/fetch';\nimport {\n createSegmentLoaderActor,\n type SegmentLoaderActor,\n type SegmentLoaderActorConfig,\n} from '../../actors/dom/segment-loader';\nimport { createSourceBufferActor, type SourceBufferActor } from '../../actors/dom/source-buffer';\nimport { failoverFetch } from '../../primitives/failover-fetch';\nimport { AUDIO_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../../primitives/track-types';\n\n/**\n * Media track type for MSE buffer setup.\n * Text tracks are excluded as they don't use MSE SourceBuffers.\n */\nexport type MediaTrackType = 'video' | 'audio';\n\nexport interface BufferActorsState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n bandwidthState?: BandwidthState;\n}\n\nexport interface BufferActorsContext {\n mediaSource?: MediaSource;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n videoSegmentLoaderActor?: SegmentLoaderActor;\n audioSegmentLoaderActor?: SegmentLoaderActor;\n}\n\ntype BufferActorsFsmState = 'preconditions-unmet' | 'buffer-ready';\n\ntype SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';\ntype BufferActorKey = 'videoBufferActor' | 'audioBufferActor';\ntype SegmentLoaderActorKey = 'videoSegmentLoaderActor' | 'audioSegmentLoaderActor';\n\ntype BufferActorsStateMap<K extends SelectedTrackKey> = {\n presentation: ReadonlySignal<BufferActorsState['presentation']>;\n} & { [P in K]: ReadonlySignal<BufferActorsState[P]> };\n\ntype BufferActorsContextMap<A extends BufferActorKey, L extends SegmentLoaderActorKey> = {\n mediaSource: ReadonlySignal<BufferActorsContext['mediaSource']>;\n} & { [P in A]: Signal<SourceBufferActor | undefined> } & {\n [P in L]: Signal<SegmentLoaderActor | undefined>;\n};\n\n// ============================================================================\n// Specialization helper\n//\n// `setupBufferActors` has the same shape as a Behavior `setup` function:\n// `({ state, context, config }) => reactor`. Each `setupXBufferActors`\n// export below calls it from inside its own `defineBehavior` setup,\n// passing its narrowed `state` / `context` through directly and supplying\n// the per-type `selectedKey`, `actorKey`, `loaderKey`, `type` discriminator,\n// and `fetch` inline.\n//\n// Gating is per-type only: no cross-type coupling. See the file-level\n// JSDoc for how the Firefox `mozHasAudio` invariant survives the split\n// via SPF's effect coalescing.\n// ============================================================================\n\nfunction setupBufferActors<K extends SelectedTrackKey, A extends BufferActorKey, L extends SegmentLoaderActorKey>({\n state,\n context,\n config,\n}: {\n state: BufferActorsStateMap<K>;\n context: BufferActorsContextMap<A, L>;\n config: {\n type: MediaTrackType;\n selectedKey: K;\n actorKey: A;\n loaderKey: L;\n fetch: FetchBytes;\n } & SegmentLoaderActorConfig;\n}): Reactor<BufferActorsFsmState | 'destroying' | 'destroyed'> {\n const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer } = config;\n const derivedStateSignal = computed<BufferActorsFsmState>(() => {\n if (!context.mediaSource.get()) return 'preconditions-unmet';\n const selection: TrackSelectionState = {\n presentation: state.presentation.get(),\n [selectedKey]: state[selectedKey].get(),\n };\n return hasCodecs(getSelectedTrack(selection, type)) ? 'buffer-ready' : 'preconditions-unmet';\n });\n\n return createMachineReactor<BufferActorsFsmState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n 'buffer-ready': {\n entry: () => {\n const mediaSource = context.mediaSource.get()!;\n const selection: TrackSelectionState = {\n presentation: state.presentation.get(),\n [selectedKey]: state[selectedKey].get(),\n };\n const track = getSelectedTrack(selection, type) as PartiallyResolvedTrack;\n const buffer = createSourceBuffer(mediaSource, buildMimeCodec(track));\n const bufferActor = createSourceBufferActor(buffer);\n const segmentLoader = createSegmentLoaderActor(bufferActor, fetch, { forwardBuffer, backBuffer });\n\n // Synchronous slot writes — load-bearing for the Firefox\n // `mozHasAudio` invariant (see file-level JSDoc). Both per-type\n // entries' `addSourceBuffer` calls must land in the same\n // `runPending` iteration; synchronous writes keep the contiguous\n // JS frame. Downstream `loadXSegments` effects fire in the\n // *next* `runPending`.\n context[actorKey].set(bufferActor);\n context[loaderKey].set(segmentLoader);\n\n // State-exit cleanup — fires when `mediaSource` detaches, the\n // selection unsets, or the behavior is destroyed. Destroy the\n // loader before its upstream buffer-actor so any in-flight\n // `appendBuffer` is aborted before the buffer-actor's own\n // teardown.\n return () => {\n segmentLoader.destroy();\n bufferActor.destroy();\n context[loaderKey].set(undefined);\n context[actorKey].set(undefined);\n };\n },\n },\n },\n });\n}\n\n// ============================================================================\n// Specialized exports — one per media type\n// ============================================================================\n\n/**\n * Set up the video `SourceBufferActor` + `SegmentLoaderActor`. Fires\n * when `mediaSource` is attached and the selected video track is\n * present in the presentation with codecs. Gates only on video state —\n * no cross-type coupling. Owns a bandwidth-sampling `trackedFetch` and\n * is sole writer of `state.bandwidthState`.\n */\nexport const setupVideoBufferActors = defineBehavior({\n stateKeys: ['presentation', 'selectedVideoTrackId', 'bandwidthState'] as const,\n contextKeys: ['mediaSource', 'videoBufferActor', 'videoSegmentLoaderActor'] as const,\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: BufferActorsStateMap<'selectedVideoTrackId'> & {\n bandwidthState: Signal<BufferActorsState['bandwidthState']>;\n };\n context: BufferActorsContextMap<'videoBufferActor', 'videoSegmentLoaderActor'>;\n config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId };\n }) => {\n // Bandwidth-sampling fetch. The factory accumulates EWMA state\n // internally; the callback bridges samples to engine state for ABR.\n // Created once at behavior setup time so the EWMA accumulates across\n // source resets (each per-source loader-actor receives the same\n // tracked fetch when reconstructed in `buffer-ready` entry).\n const trackedFetch = createTrackedFetch(\n state.bandwidthState.get() ?? {\n fastEstimate: 0,\n fastTotalWeight: 0,\n slowEstimate: 0,\n slowTotalWeight: 0,\n bytesSampled: 0,\n },\n (next) => state.bandwidthState.set(next)\n );\n // Engine `config` layers over the per-type defaults; `failoverFetch` reads\n // its `selectedKey` + `getCdnId` from the merged result.\n const typeConfig = { ...VIDEO_TYPE_CONFIG, ...config };\n return setupBufferActors({\n state,\n context,\n config: { ...typeConfig, fetch: failoverFetch(trackedFetch, state, typeConfig) },\n });\n },\n});\n\n/**\n * Set up the audio `SourceBufferActor` + `SegmentLoaderActor`. Same\n * shape as `setupVideoBufferActors`, narrowed to audio. Today supplies\n * a non-sampling `fetchStream` (no audio ABR); adding audio ABR is a\n * localized change to this setup body (swap `fetchStream` for a\n * `createTrackedFetch` call + declare `bandwidthState` writable here)\n * without touching the shared helper. See\n * `internal/design/spf/features/audio-abr.md` for the design surface\n * (bandwidth-state sharing, multi-writer coordination, EWMA mixed-\n * source sampling).\n *\n * **Mid-stream audio track switching is NOT this behavior's concern.**\n * Slot writes to `selectedAudioTrackId` (default selection, programmatic\n * filter-driven, future ABR) are owned by `switchAudioTrack` / future\n * `switchAudioQuality` in `track-switching.ts`. Flush orchestration on\n * track change is dispatched from there via `audioBufferActor.send(...)`\n * — keeping this setup behavior focused on per-source actor lifecycle.\n */\nexport const setupAudioBufferActors = defineBehavior({\n stateKeys: ['presentation', 'selectedAudioTrackId'] as const,\n contextKeys: ['mediaSource', 'audioBufferActor', 'audioSegmentLoaderActor'] as const,\n setup: ({\n state,\n context,\n config = {},\n }: {\n state: BufferActorsStateMap<'selectedAudioTrackId'>;\n context: BufferActorsContextMap<'audioBufferActor', 'audioSegmentLoaderActor'>;\n config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId };\n }) => {\n // Key order mirrors setupVideoBufferActors.\n const typeConfig = { ...AUDIO_TYPE_CONFIG, ...config };\n return setupBufferActors({\n state,\n context,\n config: { ...typeConfig, fetch: failoverFetch(fetchStream, state, typeConfig) },\n });\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqIA,SAAS,kBAAyG,EAChH,OACA,SACA,UAW6D;CAC7D,MAAM,EAAE,MAAM,aAAa,UAAU,WAAW,OAAO,eAAe,eAAe;CACrF,MAAM,qBAAqB,eAAqC;AAC9D,MAAI,CAAC,QAAQ,YAAY,KAAK,CAAE,QAAO;AAKvC,SAAO,UAAU,iBAJsB;GACrC,cAAc,MAAM,aAAa,KAAK;IACrC,cAAc,MAAM,aAAa,KAAK;GACxC,EAC4C,KAAK,CAAC,GAAG,iBAAiB;GACvE;AAEF,QAAO,qBAA2C;EAChD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GAEzB,gBAAgB,EACd,aAAa;IAQX,MAAM,cAAc,wBADL,mBANK,QAAQ,YAAY,KAAK,EAME,eADjC,iBAJyB;KACrC,cAAc,MAAM,aAAa,KAAK;MACrC,cAAc,MAAM,aAAa,KAAK;KACxC,EACyC,KAAK,CACqB,CAAC,CAClB;IACnD,MAAM,gBAAgB,yBAAyB,aAAa,OAAO;KAAE;KAAe;KAAY,CAAC;AAQjG,YAAQ,UAAU,IAAI,YAAY;AAClC,YAAQ,WAAW,IAAI,cAAc;AAOrC,iBAAa;AACX,mBAAc,SAAS;AACvB,iBAAY,SAAS;AACrB,aAAQ,WAAW,IAAI,KAAA,EAAU;AACjC,aAAQ,UAAU,IAAI,KAAA,EAAU;;MAGrC;GACF;EACF,CAAC;;;;;;;;;AAcJ,MAAa,yBAAyB,eAAe;CACnD,WAAW;EAAC;EAAgB;EAAwB;EAAiB;CACrE,aAAa;EAAC;EAAe;EAAoB;EAA0B;CAC3E,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAOP;EAMJ,MAAM,eAAe,mBACnB,MAAM,eAAe,KAAK,IAAI;GAC5B,cAAc;GACd,iBAAiB;GACjB,cAAc;GACd,iBAAiB;GACjB,cAAc;GACf,GACA,SAAS,MAAM,eAAe,IAAI,KAAK,CACzC;EAGD,MAAM,aAAa;GAAE,GAAG;GAAmB,GAAG;GAAQ;AACtD,SAAO,kBAAkB;GACvB;GACA;GACA,QAAQ;IAAE,GAAG;IAAY,OAAO,cAAc,cAAc,OAAO,WAAW;IAAE;GACjF,CAAC;;CAEL,CAAC;;;;;;;;;;;;;;;;;;;AAoBF,MAAa,yBAAyB,eAAe;CACnD,WAAW,CAAC,gBAAgB,uBAAuB;CACnD,aAAa;EAAC;EAAe;EAAoB;EAA0B;CAC3E,QAAQ,EACN,OACA,SACA,SAAS,EAAE,OAKP;EAEJ,MAAM,aAAa;GAAE,GAAG;GAAmB,GAAG;GAAQ;AACtD,SAAO,kBAAkB;GACvB;GACA;GACA,QAAQ;IAAE,GAAG;IAAY,OAAO,cAAc,aAAa,OAAO,WAAW;IAAE;GAChF,CAAC;;CAEL,CAAC"}
@@ -11,11 +11,13 @@ import { listen } from "@videojs/utils/dom";
11
11
  * available, allocate one slot in `mediaElement.textTracks` per model text
12
12
  * track — via creating `<track>` children, since that's the only spec
13
13
  * mechanism for adding *and* removing entries to `textTracks` (no
14
- * `removeTextTrack` API exists). Once slots are provisioned, mirror
15
- * `selectedTextTrackId` into their `mode`s, and propagate user-initiated
16
- * DOM `change` events back to `selectedTextTrackId` so non-SPF consumers
17
- * (host-page captions buttons, browser native UI, video.js store) remain
18
- * the canonical write path for selection.
14
+ * `removeTextTrack` API exists). Once slots are provisioned, mirror the
15
+ * resolved `selectedTextTrackId` into their `mode`s (one-way: state → DOM),
16
+ * and propagate user-initiated DOM `change` events back to
17
+ * `userTextTrackSelection` the standing *intent* (a language-based partial,
18
+ * or `'off'`) that `switchTextTrack` resolves into `selectedTextTrackId`. So
19
+ * non-SPF consumers (host-page captions buttons, browser native UI, video.js
20
+ * store) drive selection by expressing intent, not by writing the resolved id.
19
21
  *
20
22
  * Single-positive-state reactor (`'preconditions-unmet'` ↔ `'sync-active'`):
21
23
  * the entry allocates the slots, applies the initial selection, attaches
@@ -34,19 +36,37 @@ import { listen } from "@videojs/utils/dom";
34
36
  * presentation reusing a trackId would have `getSegmentsToLoad` treat
35
37
  * its segments as already-buffered and skip loading them.
36
38
  *
37
- * Multi-writer with `selectTextTrack` is intentional and orthogonal:
38
- * `selectTextTrack` owns the *default-on-load / clear-on-unload* contract
39
- * for `selectedTextTrackId`; this behavior writes only from DOM `change`
40
- * events. They reflect distinct decision-making domains (config-driven
41
- * intent vs DOM-driven user action), so this behavior never clears the
42
- * slot on source unload that's `selectTextTrack`'s contract. (It *does*
43
- * write `undefined` when a user disables all tracks via native UI, since
44
- * that's a real user action that should round-trip into SPF state.)
39
+ * Single-writer separation: `selectedTextTrackId` is the resolved *output*
40
+ * owned solely by `switchTextTrack`; this behavior only reads it (to mirror
41
+ * modes). The write path here is `userTextTrackSelection` the user-intent
42
+ * *input* so DOM action and the resolver never contend for one slot. The
43
+ * intent isn't cleared on source unload (it's a standing preference, like
44
+ * `userAudioTrackSelection`); `'off'` is written when the user disables all
45
+ * tracks via native UI.
46
+ *
47
+ * Echo guard: `selectedTextTrackId` is exactly the id this behavior last drove
48
+ * into the DOM, so a `change` event still showing it is our own echo (or a
49
+ * resolver-driven correction — e.g. the picked track's CDN failed and the
50
+ * resolver disabled it) and is ignored, never written back as a spurious user
51
+ * action. Only a showing id that *differs* from the resolved id is a real user
52
+ * pick. The settling-window guard additionally swallows Chromium's init-time
53
+ * auto-selection before the resolved selection has settled.
45
54
  */
46
55
  function deriveState(presentation, mediaElement) {
47
56
  if (!mediaElement || !presentation) return "preconditions-unmet";
48
57
  return getTracksByType(presentation, "text").length > 0 ? "sync-active" : "preconditions-unmet";
49
58
  }
59
+ /**
60
+ * Map the DOM-showing track back to standing user intent. No showing track is an
61
+ * explicit `'off'`. Otherwise prefer a language-based partial (so the pick
62
+ * persists across source changes); fall back to `{ id }` for a track without a
63
+ * language (precise within a source, just not portable).
64
+ */
65
+ function deriveTextTrackIntent(showingId, modelTextTracks) {
66
+ if (!showingId) return "off";
67
+ const language = modelTextTracks.find((track) => track.id === showingId)?.language;
68
+ return language ? { language } : { id: showingId };
69
+ }
50
70
  function syncTextTracksSetup({ state, context, config }) {
51
71
  const { addSubtitlesTracksToMedia, getShowingSubtitlesTrackFromMedia, removeAllSubtitlesTracksFromMedia } = config;
52
72
  const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaElement.get()));
@@ -58,7 +78,8 @@ function syncTextTracksSetup({ state, context, config }) {
58
78
  "sync-active": {
59
79
  entry: () => {
60
80
  const mediaElement = context.mediaElement.get();
61
- addSubtitlesTracksToMedia(mediaElement, getTracksByType(state.presentation.get(), "text"));
81
+ const modelTextTracks = getTracksByType(state.presentation.get(), "text");
82
+ addSubtitlesTracksToMedia(mediaElement, modelTextTracks);
62
83
  syncTextTrackModes(mediaElement.textTracks, state.selectedTextTrackId.get());
63
84
  let inSettlingWindow = true;
64
85
  const settlingTimeout = setTimeout(() => {
@@ -69,9 +90,9 @@ function syncTextTracksSetup({ state, context, config }) {
69
90
  syncTextTrackModes(mediaElement.textTracks, state.selectedTextTrackId.get());
70
91
  return;
71
92
  }
72
- const nextId = getShowingSubtitlesTrackFromMedia(mediaElement)?.id || void 0;
73
- if (nextId === state.selectedTextTrackId.get()) return;
74
- state.selectedTextTrackId.set(nextId);
93
+ const showingId = getShowingSubtitlesTrackFromMedia(mediaElement)?.id || void 0;
94
+ if (showingId === state.selectedTextTrackId.get()) return;
95
+ state.userTextTrackSelection.set(deriveTextTrackIntent(showingId, modelTextTracks));
75
96
  };
76
97
  const unlisten = listen(mediaElement.textTracks, "change", onChange);
77
98
  return () => {
@@ -89,7 +110,11 @@ function syncTextTracksSetup({ state, context, config }) {
89
110
  });
90
111
  }
91
112
  const syncTextTracks = defineBehavior({
92
- stateKeys: ["presentation", "selectedTextTrackId"],
113
+ stateKeys: [
114
+ "presentation",
115
+ "selectedTextTrackId",
116
+ "userTextTrackSelection"
117
+ ],
93
118
  contextKeys: ["mediaElement", "textTracksActor"],
94
119
  setup: syncTextTracksSetup
95
120
  });