@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
@@ -0,0 +1,341 @@
1
+ import { computed, peek } from "../../core/signals/primitives.js";
2
+ import { defineBehavior } from "../../core/composition/create-composition.js";
3
+ import { createMachineReactor } from "../../core/reactors/create-machine-reactor.js";
4
+ import { isResolvedPresentation } from "../../media/types/index.js";
5
+ import { getTracksByType } from "../../media/utils/tracks.js";
6
+ import { getCdnId } from "../../media/utils/cdn.js";
7
+ import { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate } from "../../network/bandwidth-estimator.js";
8
+ import { DEFAULT_QUALITY_CONFIG, resolutionArea } from "../../media/abr/quality-selection.js";
9
+ import { matchesPartialTrack, pickTextTrackFromTracks } from "../../media/primitives/select-tracks.js";
10
+ /**
11
+ * Apply rules to a candidate list in order; the pick is the first survivor.
12
+ * Two responsibilities the rules don't carry: a rule that returns nothing is
13
+ * skipped (fall-through — a preference never empties the set), and once one
14
+ * survivor remains the chain stops (early-bail — later rules, including the
15
+ * bandwidth ranker, never run, so the effect doesn't subscribe to their
16
+ * signals while the choice is fixed).
17
+ *
18
+ * @param rules - Rules to apply, most authoritative first
19
+ * @param tracks - Candidate tracks
20
+ * @param deps - The behavior's `{ state, context, config }`, passed through to each rule
21
+ * @returns The surviving candidates, pick first
22
+ */
23
+ function applyRules(rules, tracks, deps) {
24
+ let current = tracks;
25
+ for (const rule of rules) {
26
+ const remaining = rule(current, deps);
27
+ if (remaining.length === 0) continue;
28
+ current = remaining;
29
+ if (current.length === 1) break;
30
+ }
31
+ return current;
32
+ }
33
+ /**
34
+ * Apply hard constraints to a candidate list — the pre-pass that runs before the
35
+ * rule chain. A constraint shares a rule's signature but its exclusion is
36
+ * *hard*: it removes the unplayable (a codec the environment can't decode, a CDN
37
+ * in failover cooldown) and a removed track is never attempted. Unlike
38
+ * `applyRules`, this never skips an empty result and never early-bails — every
39
+ * constraint always applies, and an empty survivor set is a real outcome
40
+ * ("nothing playable here"), not a fall-through. Because each constraint only
41
+ * removes, the order they run in can't change the result.
42
+ *
43
+ * @param constraints - Constraints to apply (pooled, order-independent)
44
+ * @param tracks - Candidate tracks
45
+ * @param deps - The behavior's `{ state, context, config }`, passed to each constraint
46
+ * @returns The playable survivors (possibly empty)
47
+ */
48
+ function applyConstraints(constraints, tracks, deps) {
49
+ let current = tracks;
50
+ for (const constraint of constraints) current = constraint(current, deps);
51
+ return current;
52
+ }
53
+ /**
54
+ * User intent — a soft filter. Narrows to tracks matching the partial-track
55
+ * selection in `user*TrackSelection`; an empty match falls through (the
56
+ * composer skips it) to the unfiltered set — e.g. a stale id from a previous
57
+ * source.
58
+ */
59
+ function filterByUserSelection(tracks, { state, config }) {
60
+ const key = config.userSelectionKey;
61
+ if (!key) return tracks;
62
+ const filter = state[key]?.get();
63
+ return filter ? tracks.filter((track) => matchesPartialTrack(track, filter)) : tracks;
64
+ }
65
+ /**
66
+ * Failed-CDN constraint — a *hard* filter (constraints pre-pass), shared by
67
+ * video and audio. Removes tracks served from a CDN currently in failover
68
+ * cooldown (`failedCdns`, written by the failover monitor). Removed tracks are never
69
+ * attempted; the scope then narrows to the next surviving CDN in `cdnPriority`,
70
+ * and snaps back to the primary once it leaves cooldown.
71
+ *
72
+ * Passes everything through when there's no `failedCdns` signal/value. When it
73
+ * prunes *every* track (all CDNs cooled down), the empty result is preserved
74
+ * (per `applyConstraints`) — "nothing playable," which clears the selection (no
75
+ * pick); a later CDN recovery refills the candidate set and re-picks.
76
+ */
77
+ function excludeFailedCdns(tracks, { state, config }) {
78
+ const failed = state.failedCdns?.get();
79
+ if (!failed?.length) return tracks;
80
+ const getCdnId$1 = config.getCdnId ?? getCdnId;
81
+ const failedSet = new Set(failed);
82
+ return tracks.filter((track) => !failedSet.has(getCdnId$1(track.url)));
83
+ }
84
+ /**
85
+ * Capability constraint — a *hard* filter (constraints pre-pass), shared by
86
+ * video and audio. Removes renditions this environment can't decode, probed via
87
+ * the injected `canPlayTrack` (codec → `MediaSource.isTypeSupported`). Moving
88
+ * the check here — before selection — means an unplayable variant (e.g. HEVC on
89
+ * a browser without HEVC) is pruned upstream and never picked, instead of
90
+ * surviving into the pipeline to fail late at `createSourceBuffer`. That late
91
+ * throw stays as a defensive structural guarantee; with this constraint it
92
+ * should rarely fire.
93
+ *
94
+ * Passes everything through when there's no `canPlayTrack` probe (a composition
95
+ * that didn't wire it, or DOM-free tests). When it prunes *every* track (no
96
+ * decodable rendition), the empty result is preserved (per `applyConstraints`)
97
+ * — "nothing playable," so the behavior clears the selection (no pick) and the
98
+ * late `createSourceBuffer` check stays as the backstop.
99
+ */
100
+ function excludeUnplayableTracks(tracks, { config }) {
101
+ const canPlay = config.canPlayTrack;
102
+ if (!canPlay) return tracks;
103
+ return tracks.filter((track) => canPlay(track));
104
+ }
105
+ /**
106
+ * Active-CDN scope — a soft filter, shared by video and audio. Narrows to the
107
+ * highest-priority CDN in `cdnPriority` (owned by `deriveCdnPriority`) that
108
+ * still has tracks, so every track type stays on one CDN. A redundant-streams
109
+ * source lists the same renditions on multiple hosts; this keeps the pick on one
110
+ * host rather than letting the ranker drift across them.
111
+ *
112
+ * "Active" is derived, not stored: constraints run before the rule chain, so a
113
+ * failed CDN's tracks are already pruned by the time this runs — "first CDN with
114
+ * survivors" *is* the active CDN, and it falls through to the next on failover
115
+ * (and snaps back to the primary when it recovers). Content steering reorders
116
+ * `cdnPriority`; this rule just honors the order.
117
+ *
118
+ * Soft-filter semantics: passes through when there's no `cdnPriority` signal/value
119
+ * (no preference) or when nothing matches (`applyRules` skips an empty result).
120
+ * Non-redundant sources have one CDN, so the narrow is a no-op.
121
+ *
122
+ * The CDN-id derivation defaults to origin-based `getCdnId`, overridable via the
123
+ * `getCdnId` config — it must match the one `deriveCdnPriority` used to build
124
+ * `cdnPriority`, or no track's CDN would ever equal an entry.
125
+ */
126
+ function preferActiveCdn(tracks, { state, config }) {
127
+ const cdnPriority = state.cdnPriority?.get();
128
+ if (!cdnPriority?.length) return tracks;
129
+ const getCdnId$2 = config.getCdnId ?? getCdnId;
130
+ for (const cdn of cdnPriority) {
131
+ const tracksUsingCdn = tracks.filter((track) => getCdnId$2(track.url) === cdn);
132
+ if (tracksUsingCdn.length) return tracksUsingCdn;
133
+ }
134
+ return tracks;
135
+ }
136
+ /**
137
+ * Bandwidth ranking — the terminal sort, shared by video and audio. Orders by
138
+ * the throughput estimate: tracks within the bandwidth threshold first
139
+ * (fitting), highest bitrate first; then over-threshold tracks, least-over
140
+ * first. The head is the best-quality track that fits, falling back to the
141
+ * smallest over-throughput track when nothing fits.
142
+ *
143
+ * Hysteresis without temporal state: the current track's effective bitrate is
144
+ * boosted by `upgradeMargin` in the fitting sort, so a higher track only
145
+ * outranks it once it clears `current.bitrate * upgradeMargin` (no flapping on
146
+ * marginal bandwidth gains). Downgrades fall out for free — a current track
147
+ * over the threshold isn't in the fitting set to be boosted, so the best fit (a
148
+ * downgrade) wins immediately. Equal-bitrate tracks break by resolution (higher
149
+ * `width × height` first), so an equal-bitrate ladder never picks a lower-
150
+ * quality rendition by manifest order; audio tracks carry no dimensions, so
151
+ * they area-compare equal and a stable sort keeps their candidate order (e.g.
152
+ * same-bitrate language variants). Early-bail skips this rule when a prior one
153
+ * narrowed to a single track, so the estimate is neither read nor subscribed
154
+ * while that holds.
155
+ */
156
+ function rankByBandwidth(tracks, { state, config }) {
157
+ const safetyMargin = config.quality?.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;
158
+ const upgradeMargin = config.quality?.upgradeMargin ?? DEFAULT_QUALITY_CONFIG.upgradeMargin;
159
+ const initialBandwidth = config.initialBandwidth ?? 5e6;
160
+ const bandwidthConfig = {
161
+ ...DEFAULT_BANDWIDTH_CONFIG,
162
+ ...config.bandwidth
163
+ };
164
+ if (!state.bandwidthState) console.debug("[track-switching] rankByBandwidth: no bandwidthState signal in composition; ranking on initialBandwidth");
165
+ const threshold = getBandwidthEstimate(state.bandwidthState?.get(), initialBandwidth, bandwidthConfig) * safetyMargin;
166
+ const currentId = state[config.selectionKey].get();
167
+ const bitrate = (track) => track.bandwidth ?? 0;
168
+ const rank = (track) => track.id === currentId ? bitrate(track) * upgradeMargin : bitrate(track);
169
+ const fitting = tracks.filter((track) => bitrate(track) <= threshold).sort((a, b) => rank(b) - rank(a) || resolutionArea(b) - resolutionArea(a));
170
+ const over = tracks.filter((track) => bitrate(track) > threshold).sort((a, b) => bitrate(a) - bitrate(b) || resolutionArea(b) - resolutionArea(a));
171
+ return [...fitting, ...over];
172
+ }
173
+ /**
174
+ * Default final pick: the chain head. `applyRules` never narrows to nothing and
175
+ * early-bails to a single survivor, so video and audio always converge to a
176
+ * track and the head is the pick.
177
+ */
178
+ function selectChainHead(candidates) {
179
+ return candidates[0].id;
180
+ }
181
+ /**
182
+ * Terminal pick for text — the `resolveSelection` the text variant supplies.
183
+ * Resolves the standing `userTextTrackSelection` intent against the chain's
184
+ * survivors (already CDN-failover-pruned and active-CDN-scoped):
185
+ *
186
+ * - `'off'` → no selection (clear the slot). Sticky through re-evaluation, so a
187
+ * live refresh or failover re-run can't re-assert a default.
188
+ * - explicit `Partial<TextTrack>` → narrow to the match (language-based). A
189
+ * stale pick whose match is gone (e.g. the language dropped on a source
190
+ * change) falls through to the default policy.
191
+ * - auto (`undefined`) → the opt-in default policy (`preferredSubtitleLanguage`
192
+ * → `DEFAULT=YES + AUTOSELECT=YES` → none), shared with `pickTextTrack`.
193
+ *
194
+ * Returning `undefined` is a real outcome (captions are opt-in), which is why the
195
+ * text variant relies on `setupTrackSwitching`'s no-selection seam.
196
+ */
197
+ function pickResolvedTextTrack(candidates, { state, config }) {
198
+ const intent = state.userTextTrackSelection?.get();
199
+ if (intent === "off") return void 0;
200
+ if (intent) {
201
+ const matched = candidates.filter((track) => matchesPartialTrack(track, intent));
202
+ if (matched.length) return matched[0].id;
203
+ }
204
+ return pickTextTrackFromTracks(candidates, config);
205
+ }
206
+ function setupTrackSwitching(deps) {
207
+ const { state, config } = deps;
208
+ const { selectionKey, getTracks, rules, resolveSelection = selectChainHead } = config;
209
+ const derivedStateSignal = computed(() => isResolvedPresentation(state.presentation.get()) ? "presentation-resolved" : "presentation-unresolved");
210
+ const candidateSet = computed(() => {
211
+ const presentation = state.presentation.get();
212
+ if (!isResolvedPresentation(presentation)) return [];
213
+ return applyConstraints(config.constraints ?? [], getTracks(presentation), deps);
214
+ }, { equals: (a, b) => a.length === b.length && a.every((track) => b.some((other) => other.id === track.id)) });
215
+ return createMachineReactor({
216
+ initial: "presentation-unresolved",
217
+ monitor: () => derivedStateSignal.get(),
218
+ states: {
219
+ "presentation-unresolved": {},
220
+ "presentation-resolved": {
221
+ entry: () => () => state[selectionKey].set(void 0),
222
+ effects: [() => {
223
+ const tracks = candidateSet.get();
224
+ if (!tracks.length) {
225
+ const presentation = peek(state.presentation);
226
+ if (isResolvedPresentation(presentation) && getTracks(presentation).length > 0) {
227
+ console.error(`[track-switching] every ${selectionKey} candidate was filtered out by constraints; clearing selection`);
228
+ state[selectionKey].set(void 0);
229
+ }
230
+ return;
231
+ }
232
+ const candidates = applyRules(rules, tracks, deps);
233
+ if (!candidates.length) {
234
+ console.error("[track-switching] applyRules returned no candidates");
235
+ return;
236
+ }
237
+ state[selectionKey].set(resolveSelection(candidates, deps));
238
+ }]
239
+ }
240
+ }
241
+ });
242
+ }
243
+ /**
244
+ * Manage `selectedVideoTrackId`: pick a default on src load, dynamically
245
+ * adjust based on bandwidth, clear on src unload. Honors
246
+ * `userVideoTrackSelection` as a partial-track constraint on candidates;
247
+ * short-circuits ABR when the constraint narrows to a single track.
248
+ *
249
+ * @example
250
+ * const reactor = switchVideoTrack.setup({ state });
251
+ */
252
+ const switchVideoTrack = defineBehavior({
253
+ stateKeys: ["presentation", "selectedVideoTrackId"],
254
+ contextKeys: [],
255
+ setup: ({ state, config, ...otherProps }) => setupTrackSwitching({
256
+ ...otherProps,
257
+ state,
258
+ config: {
259
+ ...config,
260
+ selectionKey: "selectedVideoTrackId",
261
+ userSelectionKey: "userVideoTrackSelection",
262
+ getTracks: (presentation) => getTracksByType(presentation, "video"),
263
+ constraints: [excludeFailedCdns, excludeUnplayableTracks],
264
+ rules: [
265
+ filterByUserSelection,
266
+ preferActiveCdn,
267
+ rankByBandwidth
268
+ ]
269
+ }
270
+ })
271
+ });
272
+ /**
273
+ * Manage `selectedAudioTrackId`: pick a default on src load, narrow by
274
+ * `userAudioTrackSelection` filter, re-pick on filter change, clear on
275
+ * src unload.
276
+ *
277
+ * Mid-stream flush on language switch is handled by the segment-loader's
278
+ * `planTasks` (see `playback/actors/dom/segment-loader.ts`) — not this
279
+ * behavior. Same split as the video pipeline: slot owner writes; loader
280
+ * orchestrates segment + flush plans.
281
+ *
282
+ * @example
283
+ * const reactor = switchAudioTrack.setup({ state });
284
+ */
285
+ const switchAudioTrack = defineBehavior({
286
+ stateKeys: ["presentation", "selectedAudioTrackId"],
287
+ contextKeys: [],
288
+ setup: ({ state, config, ...otherProps }) => setupTrackSwitching({
289
+ ...otherProps,
290
+ state,
291
+ config: {
292
+ ...config,
293
+ selectionKey: "selectedAudioTrackId",
294
+ userSelectionKey: "userAudioTrackSelection",
295
+ getTracks: (presentation) => getTracksByType(presentation, "audio"),
296
+ constraints: [excludeFailedCdns, excludeUnplayableTracks],
297
+ rules: [
298
+ filterByUserSelection,
299
+ preferActiveCdn,
300
+ rankByBandwidth
301
+ ]
302
+ }
303
+ })
304
+ });
305
+ /**
306
+ * Manage `selectedTextTrackId` as the single-writer **output** of standing user
307
+ * intent (`userTextTrackSelection`) resolved against the playable, CDN-scoped
308
+ * text renditions: clear on src unload; re-resolve when a CDN fails or recovers.
309
+ *
310
+ * Unlike video/audio, the selection is *optional* — captions are opt-in and the
311
+ * user can turn them off — so the chain skips the bandwidth ranker and the shared
312
+ * user-selection filter, and supplies a text-specific terminal
313
+ * (`pickResolvedTextTrack`) that may resolve to no-selection via
314
+ * `setupTrackSwitching`'s `resolveSelection` seam. Constraints are failed-CDN only
315
+ * (`excludeUnplayableTracks`/`canPlayTrack` is MSE-based — the wrong probe for
316
+ * text, whose playability is SPF-parser support); the active-CDN scope co-locates
317
+ * captions with the surviving CDN on failover.
318
+ *
319
+ * @example
320
+ * const reactor = switchTextTrack.setup({ state, config: { preferredSubtitleLanguage: 'en' } });
321
+ */
322
+ const switchTextTrack = defineBehavior({
323
+ stateKeys: ["presentation", "selectedTextTrackId"],
324
+ contextKeys: [],
325
+ setup: ({ state, config, ...otherProps }) => setupTrackSwitching({
326
+ ...otherProps,
327
+ state,
328
+ config: {
329
+ ...config,
330
+ selectionKey: "selectedTextTrackId",
331
+ getTracks: (presentation) => getTracksByType(presentation, "text"),
332
+ constraints: [excludeFailedCdns],
333
+ rules: [preferActiveCdn],
334
+ resolveSelection: pickResolvedTextTrack
335
+ }
336
+ })
337
+ });
338
+ //#endregion
339
+ export { switchAudioTrack, switchTextTrack, switchVideoTrack };
340
+
341
+ //# sourceMappingURL=track-switching.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track-switching.js","names":["getCdnId","defaultGetCdnId"],"sources":["../../../../src/playback/behaviors/track-switching.ts"],"sourcesContent":["/**\n * **Per-type track selection as a rule chain.** While a presentation is\n * resolved, owns that type's `selected{Video,Audio,Text}TrackId` signal: pick a\n * default, react to user intent and algorithmic ranking, and clear it on src\n * unload.\n *\n * Selection runs in two stages. First a **hard-constraints pre-pass**\n * (`applyConstraints`) prunes the unplayable from the candidate set — the\n * failed-CDN constraint (`excludeFailedCdns`, failover cooldown) and the\n * capability constraint (`excludeUnplayableTracks`, codec support). Then a small\n * ordered chain of rules (`applyRules`) picks among the survivors. Each constraint/rule reads the signals it needs at apply\n * time, so the effect subscribes to exactly what was consulted. The chain is\n * three rules, most authoritative first:\n *\n * 1. **user intent** — a soft filter on `user*TrackSelection`: narrow to the\n * partial-track match; an empty match falls through to the full set.\n * 2. **active CDN** — a soft filter on `cdnPriority` (`preferActiveCdn`):\n * narrow to the highest-priority CDN that still has tracks; an empty match\n * falls through. Shared by video and audio, so every type stays on one CDN\n * (`deriveCdnPriority` owns the list). No-op for non-redundant sources.\n * 3. **ranking** — the terminal sort: `rankByBandwidth`, shared by video and\n * audio. Fitting tracks (within the throughput threshold) first, highest\n * bitrate first; over-throughput tracks after, least-over first. Hysteresis\n * via boosting the current track's sort weight by `upgradeMargin`.\n *\n * The composer's early-bail (one survivor → stop) is load-bearing: a user\n * selection that narrows to a single track is the pick without the ranker\n * running, so the bandwidth estimate is never read and the effect doesn't\n * re-fire on bandwidth while that choice holds.\n *\n * Lifecycle: `'presentation-unresolved'` ↔ `'presentation-resolved'`. The\n * resolved state owns the signal; its entry-returned cleanup clears it on exit\n * (canonical cleanup-binds-to-setup per `reactors.md`).\n *\n * The pick is the chain's result mapped to a slot value by `resolveSelection`\n * (default: the head, `applyRules(...)[0]`). Each variant supplies its\n * **constraints + rule chain (+ optional resolveSelection)** via config;\n * `setupTrackSwitching` owns only the lifecycle and runs what it's given. Video\n * and audio run constraints `[excludeFailedCdns, excludeUnplayableTracks]` then\n * rules `[filterByUserSelection, preferActiveCdn, rankByBandwidth]` and take the\n * head; `switchVideoTrack` also accepts ABR tuning config, `switchAudioTrack`\n * takes none. `switchTextTrack` differs — selection is *optional* (captions are\n * opt-in / off-able), so it runs `[excludeFailedCdns]` + `[preferActiveCdn]` and\n * supplies a text terminal (`pickResolvedTextTrack`) that resolves standing user\n * intent (`userTextTrackSelection`, incl. `'off'`) and may yield no selection.\n * (The active-CDN *scope* is the sticky-pick half of multi-CDN; the failed-CDN\n * *constraint* is the failover half — prune the cooled-down CDN, the scope falls\n * to the next.)\n *\n * When the pre-pass prunes a type's candidates to empty, the behavior leaves any\n * prior pick in place and makes no new pick; the late `createSourceBuffer` check\n * stays as the structural backstop for an unplayable rendition reaching the\n * pipeline. Surfacing \"nothing playable\" as observable state is deferred until a\n * consumer (error mapping) needs it.\n *\n * Deferred: audio's preferred-language / default-track selection as standing\n * soft-filter rules (previously the empty-slot picker, dropped in the move to\n * the rule chain).\n */\n\nimport { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition';\nimport { createMachineReactor } from '../../core/reactors/create-machine-reactor';\nimport { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport { DEFAULT_QUALITY_CONFIG, type QualityConfig, resolutionArea } from '../../media/abr/quality-selection';\nimport {\n matchesPartialTrack,\n pickTextTrackFromTracks,\n type TextSelectionConfig,\n} from '../../media/primitives/select-tracks';\nimport {\n type AudioTrack,\n type CanPlayTrack,\n isResolvedPresentation,\n type MaybeResolvedPresentation,\n type PartiallyResolvedAudioTrack,\n type PartiallyResolvedTextTrack,\n type PartiallyResolvedVideoTrack,\n type TextTrack,\n type VideoTrack,\n} from '../../media/types';\nimport { getCdnId as defaultGetCdnId, type GetCdnId } from '../../media/utils/cdn';\nimport { getTracksByType } from '../../media/utils/tracks';\nimport type { BandwidthConfig, BandwidthState } from '../../network/bandwidth-estimator';\nimport { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate } from '../../network/bandwidth-estimator';\n\n// ============================================================================\n// State + Config\n// ============================================================================\n\n/**\n * The slots `setupTrackSwitching` itself owns: the `presentation` gate it reads\n * and the per-type `selected*TrackId` it writes. Rule-only inputs are\n * deliberately absent — `user*TrackSelection` and `bandwidthState` belong to\n * whoever materializes them (the embedder via `shareSignals`, the buffer-actor\n * sampler), and each rule declares the signal it consults as an optional slot\n * on its own deps map, so the behavior never assumes a rule's signal exists.\n */\nexport interface TrackSwitchingState {\n presentation?: MaybeResolvedPresentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/**\n * Config for `switchVideoTrack` — the ABR tuning read by its ranker rule\n * (`rankByBandwidth`). `quality.safetyMargin` is the bandwidth-headroom\n * multiplier; `quality.upgradeMargin` the hysteresis ratio gating upgrades;\n * `bandwidth` tunes the estimator; `initialBandwidth` is the pre-sample\n * fallback. Defaults: `DEFAULT_QUALITY_CONFIG` (0.85 / 1.15),\n * `DEFAULT_BANDWIDTH_CONFIG`, `DEFAULT_INITIAL_BANDWIDTH` (5 Mbps).\n */\nexport interface SwitchVideoTrackConfig {\n quality?: Partial<QualityConfig>;\n bandwidth?: Partial<BandwidthConfig>;\n initialBandwidth?: number;\n /** Override CDN-id derivation (shared by the CDN scope + failover constraint). */\n getCdnId?: GetCdnId;\n /**\n * Codec capability probe read by the `excludeUnplayableTracks` hard\n * constraint — drops renditions this environment can't decode before\n * selection runs. Injected (rather than imported) so the DOM-free behavior\n * never reaches a DOM API directly; the engine defaults it to the\n * `MediaSource.isTypeSupported`-backed `canPlayTrack`. Absent → no codec\n * filtering (the constraint passes everything through).\n */\n canPlayTrack?: CanPlayTrack;\n}\n\n/** Default initial-bandwidth value before bandwidth measurements arrive. */\nexport const DEFAULT_INITIAL_BANDWIDTH = 5_000_000;\n\n// ============================================================================\n// Rule chain\n// ============================================================================\n\n/**\n * Deps handed to each rule and to `applyRules`, mirroring a behavior's setup\n * deps so a rule reads from the same surfaces a behavior does. `context` is\n * optional — it's threaded through but absent on direct setup calls (and\n * unread by today's rules), so the whole deps object can pass straight through.\n */\nexport interface SelectionRuleDeps<State = unknown, Context = unknown, Config = unknown> {\n state: State;\n context?: Context;\n config: Config;\n}\n\n/**\n * A selection rule narrows or reorders the candidate list. It reads the state,\n * context, and config it needs at apply time (tightly-coupled reads), so a\n * rule's `.get()`s subscribe the running effect to exactly what it consulted.\n * Returning an empty list means \"no match\" — the composer skips it, so a soft\n * filter never narrows the set to nothing. A ranker returns the list with its\n * pick at the head.\n */\nexport type SelectionRule<T, State = unknown, Context = unknown, Config = unknown> = (\n tracks: readonly T[],\n deps: SelectionRuleDeps<State, Context, Config>\n) => readonly T[];\n\n/**\n * Apply rules to a candidate list in order; the pick is the first survivor.\n * Two responsibilities the rules don't carry: a rule that returns nothing is\n * skipped (fall-through — a preference never empties the set), and once one\n * survivor remains the chain stops (early-bail — later rules, including the\n * bandwidth ranker, never run, so the effect doesn't subscribe to their\n * signals while the choice is fixed).\n *\n * @param rules - Rules to apply, most authoritative first\n * @param tracks - Candidate tracks\n * @param deps - The behavior's `{ state, context, config }`, passed through to each rule\n * @returns The surviving candidates, pick first\n */\nexport function applyRules<T, State, Context, Config>(\n rules: readonly SelectionRule<T, State, Context, Config>[],\n tracks: readonly T[],\n deps: SelectionRuleDeps<State, Context, Config>\n): readonly T[] {\n let current = tracks;\n for (const rule of rules) {\n const remaining = rule(current, deps);\n if (remaining.length === 0) continue;\n current = remaining;\n if (current.length === 1) break;\n }\n return current;\n}\n\n/**\n * Apply hard constraints to a candidate list — the pre-pass that runs before the\n * rule chain. A constraint shares a rule's signature but its exclusion is\n * *hard*: it removes the unplayable (a codec the environment can't decode, a CDN\n * in failover cooldown) and a removed track is never attempted. Unlike\n * `applyRules`, this never skips an empty result and never early-bails — every\n * constraint always applies, and an empty survivor set is a real outcome\n * (\"nothing playable here\"), not a fall-through. Because each constraint only\n * removes, the order they run in can't change the result.\n *\n * @param constraints - Constraints to apply (pooled, order-independent)\n * @param tracks - Candidate tracks\n * @param deps - The behavior's `{ state, context, config }`, passed to each constraint\n * @returns The playable survivors (possibly empty)\n */\nexport function applyConstraints<T, State, Context, Config>(\n constraints: readonly SelectionRule<T, State, Context, Config>[],\n tracks: readonly T[],\n deps: SelectionRuleDeps<State, Context, Config>\n): readonly T[] {\n let current = tracks;\n for (const constraint of constraints) current = constraint(current, deps);\n return current;\n}\n\n// ============================================================================\n// Specialization helper\n//\n// `setupTrackSwitching` has the same shape as a Behavior `setup` function:\n// `({ state, config }) => Reactor`. Each `switchXTrack` export below calls\n// it from inside its own `defineBehavior` setup. Its generics — `S` (selection\n// slot key), `T` (candidate track type), `C` (the concrete config the variant\n// builds) — all infer from the passed `state` + `config`, so the variants need\n// no explicit type arguments. `C extends TrackSwitchingConfig<S, T>` lets the\n// variant's richer config (rule-specific fields included) flow through the\n// helper untouched; the rules read those fields off their own config views.\n//\n// -- Design note: why narrow `SelectionKey` / `UserSelectionKey` unions ----\n// Goal we did not reach: have callers \"fully pass in\" the slot keys, with\n// the helper enforcing zero internal knowledge of which literals are valid.\n// What blocks it: indexing a mapped-type intersection by a generic key.\n// When `S extends keyof TrackSwitchingState` (or `string`), TS conservatively\n// treats `state[selectionKey]` as the union of every possible match across\n// the intersected mapped portions — including the fixed-key signal\n// (`presentation`) — and widens to their value-type union.\n// The sibling pattern hits the same constraint and answers it the same way:\n// `SelectedTrackKey` in `select-tracks.ts` is a hardcoded narrow union for\n// the same reason.\n//\n// Current pick is the narrow-union route because it matches siblings and\n// the unions read as documentation (\"these are the slots this helper\n// manages\") rather than restriction. Extending to a new track-switching\n// axis is one literal per union.\n// --------------------------------------------------------------------------\n// ============================================================================\n\n/**\n * Minimum candidate-track shape consumed by the helper and its rules: an `id`\n * (the pick), a `url` (the active-CDN scope derives the CDN from it), an\n * optional `bandwidth` (the ranker's throughput sort), and optional\n * `width`/`height` (the ranker's equal-bitrate tie-break — absent on audio, so\n * audio candidates area-compare equal). Every resolved/partially-resolved video\n * track carries them all; audio tracks omit the dimensions.\n */\ntype SwitchableTrack = {\n id: string;\n url: string;\n bandwidth?: number;\n width?: number;\n height?: number;\n // Read by the capability constraint to probe codec support. Optional on the\n // minimal shape; every resolved/partially-resolved video & audio candidate\n // carries them, and an absent `mimeType` makes a track unprobeable (kept).\n mimeType?: string;\n codecs?: string[];\n};\n\n/**\n * Map the rule chain's surviving candidates to the final selection id, or\n * `undefined` for a deliberate no-selection. Defaults to the chain head\n * (`selectChainHead`) — the always-pick contract video and audio rely on\n * (`applyRules` guarantees a non-empty result, so the head is always there). A\n * variant whose selection is legitimately optional (text: opt-in captions,\n * explicit off) supplies its own picker that may return `undefined`; the helper\n * writes that straight through to the slot, clearing it.\n */\nexport type ResolveSelection<T extends SwitchableTrack, State = unknown, Context = unknown, Config = unknown> = (\n candidates: readonly T[],\n deps: SelectionRuleDeps<State, Context, Config>\n) => string | undefined;\n\ntype SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';\ntype UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection';\n\n// Each mapped value references `P` so TS keeps the per-key dependency and\n// resolves `state[selectionKey]` to the right arm. `T` (track type) stays out\n// of the state map (it flows through `TrackSwitchingConfig` instead).\n//\n// Only the behavior's own lifecycle signals are required here: the presentation\n// gate and the selection slot it writes. Signals a *rule* reads but the\n// behavior doesn't — `user*TrackSelection` (the user-selection filter) and\n// `bandwidthState` (the bandwidth ranker) — are NOT here; each rule declares\n// the signal it needs as *optional* on its own deps and reads it defensively,\n// so the behavior never assumes a rule-only signal exists. Those slots are\n// materialized by whoever owns them: `shareSignals` for the consumer-input\n// `user*TrackSelection`, the buffer-actor sampler for `bandwidthState`.\nexport type TrackSwitchingStateMap<S extends SelectionKey> = {\n presentation: ReadonlySignal<TrackSwitchingState['presentation']>;\n} & { [P in S]: Signal<TrackSwitchingState[P]> };\n\n/**\n * Config `setupTrackSwitching` itself reads — its own wiring: which selection\n * slot to write and clear (`selectionKey`), how to enumerate candidate tracks\n * (`getTracks`), the optional **hard-constraints pre-pass** (`constraints`,\n * applied before the chain to prune the unplayable), and the **rule chain** to\n * run (`rules`), and how to map the chain's survivors to the final pick\n * (`resolveSelection`, defaulting to the chain head). Rule-/constraint-specific\n * config is deliberately absent — each declares the fields it reads as *optional*\n * on its own config view (`UserSelectionConfig`, `BandwidthRankerConfig`), so the\n * behavior never enumerates them. The variant builds the concrete config as this\n * base plus whatever its chain consults; it flows through untouched as the `C`\n * type param on `setupTrackSwitching`.\n */\ninterface TrackSwitchingConfig<S extends SelectionKey, T extends SwitchableTrack> {\n selectionKey: S;\n getTracks: (presentation: MaybeResolvedPresentation) => readonly T[];\n constraints?: readonly SelectionRule<T, TrackSwitchingStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>[];\n rules: readonly SelectionRule<T, TrackSwitchingStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>[];\n /**\n * Map the chain's surviving candidates to the final selection id. Optional —\n * absent means the chain head (`selectChainHead`), the always-pick path video\n * and audio use. A variant with optional selection (text) supplies one that\n * may return `undefined`.\n */\n resolveSelection?: ResolveSelection<T, TrackSwitchingStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>;\n}\n\n/**\n * State the user-selection filter reads: the lifecycle map plus an *optional*\n * user-selection slot (keyed by `U`), holding a partial-track description to\n * match against the candidates (`Partial<T>` — `{ id }`, `{ language }`,\n * `{ height }`, …). The slot exists only when the composition provides it\n * (materialized by `shareSignals`); the filter reads it defensively and no-ops\n * when it's absent (no user override).\n */\ntype UserSelectionStateMap<\n S extends SelectionKey,\n U extends UserSelectionKey,\n T extends SwitchableTrack,\n> = TrackSwitchingStateMap<S> & {\n [P in U]?: ReadonlySignal<Partial<T> | undefined>;\n};\n\n/**\n * Config the user-selection filter reads: `userSelectionKey` names the state\n * slot holding the user's selection. *Optional* on the rule's view — the base\n * config doesn't carry it, so an unwired key means \"no user selection\" and the\n * filter passes through. The variants always supply it.\n */\ntype UserSelectionConfig<\n S extends SelectionKey,\n U extends UserSelectionKey,\n T extends SwitchableTrack,\n> = TrackSwitchingConfig<S, T> & { userSelectionKey?: U };\n\n/**\n * State the bandwidth ranker reads: the lifecycle map plus an *optional*\n * `bandwidthState`. The signal exists only when the composition includes a\n * bandwidth sampler; the ranker reads it defensively and falls back to\n * `initialBandwidth` (with a debug note) when it's absent.\n */\ntype BandwidthRankerStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> & {\n bandwidthState?: ReadonlySignal<BandwidthState | undefined>;\n};\n\n/**\n * Config the bandwidth ranker reads: the ABR tuning in `SwitchVideoTrackConfig`\n * (`quality` / `bandwidth` / `initialBandwidth`), all optional with defaults.\n */\ntype BandwidthRankerConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> &\n SwitchVideoTrackConfig;\n\n/**\n * State the active-CDN scope reads: the lifecycle map plus an *optional*\n * `cdnPriority` — the manifest-ordered CDN list (most-preferred first). The\n * signal exists only when the composition includes `deriveCdnPriority` (which\n * materializes + owns it); the scope reads it defensively and passes through\n * when it's absent (no CDN preference).\n */\ntype CdnScopeStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> & {\n cdnPriority?: ReadonlySignal<string[] | undefined>;\n};\n\n/**\n * State the failed-CDN constraint reads: the lifecycle map plus an *optional*\n * `failedCdns` — the CDN ids currently in failover cooldown. The signal exists\n * only when the composition includes a failover monitor (or an external driver); the\n * constraint reads it defensively and excludes nothing when it's absent.\n */\ntype CdnConstraintStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> & {\n failedCdns?: ReadonlySignal<string[] | undefined>;\n};\n\n/**\n * Config the CDN rules read: the base config plus an *optional* `getCdnId`\n * override. Both `excludeFailedCdns` and `preferActiveCdn` derive a track's CDN\n * from its URL; the override must be the *same* one `deriveCdnPriority` and the\n * failover trip use, or the keys stop matching. Optional → defaults to the\n * origin-based `getCdnId`, so the base config (without it) stays assignable.\n */\ntype CdnRuleConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> & {\n getCdnId?: GetCdnId;\n};\n\n/**\n * Config the capability constraint reads: the base config plus an *optional*\n * `canPlayTrack` codec probe. Optional → an unwired probe means \"no codec\n * filtering\" and the constraint passes everything through, so the base config\n * (without it) stays assignable. The engine defaults it to the DOM-bound\n * `canPlayTrack`.\n */\ntype CapabilityConstraintConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> & {\n canPlayTrack?: CanPlayTrack;\n};\n\ntype VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack;\ntype AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack;\ntype TextTrackCandidate = PartiallyResolvedTextTrack | TextTrack;\n\n// ----------------------------------------------------------------------------\n// Rules — defined outside the behavior closure, parameterized only by their\n// deps. Each is generic over the slot keys + track type; the variant's\n// concrete keys instantiate it where the chain is assembled.\n// ----------------------------------------------------------------------------\n\n/**\n * User intent — a soft filter. Narrows to tracks matching the partial-track\n * selection in `user*TrackSelection`; an empty match falls through (the\n * composer skips it) to the unfiltered set — e.g. a stale id from a previous\n * source.\n */\nfunction filterByUserSelection<S extends SelectionKey, U extends UserSelectionKey, T extends SwitchableTrack>(\n tracks: readonly T[],\n { state, config }: SelectionRuleDeps<UserSelectionStateMap<S, U, T>, AnySlotMap, UserSelectionConfig<S, U, T>>\n): readonly T[] {\n const key = config.userSelectionKey;\n if (!key) return tracks;\n const filter = state[key]?.get();\n return filter ? tracks.filter((track) => matchesPartialTrack(track, filter)) : tracks;\n}\n\n/**\n * Failed-CDN constraint — a *hard* filter (constraints pre-pass), shared by\n * video and audio. Removes tracks served from a CDN currently in failover\n * cooldown (`failedCdns`, written by the failover monitor). Removed tracks are never\n * attempted; the scope then narrows to the next surviving CDN in `cdnPriority`,\n * and snaps back to the primary once it leaves cooldown.\n *\n * Passes everything through when there's no `failedCdns` signal/value. When it\n * prunes *every* track (all CDNs cooled down), the empty result is preserved\n * (per `applyConstraints`) — \"nothing playable,\" which clears the selection (no\n * pick); a later CDN recovery refills the candidate set and re-picks.\n */\nfunction excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(\n tracks: readonly T[],\n { state, config }: SelectionRuleDeps<CdnConstraintStateMap<S>, AnySlotMap, CdnRuleConfig<S, T>>\n): readonly T[] {\n const failed = state.failedCdns?.get();\n if (!failed?.length) return tracks;\n const getCdnId = config.getCdnId ?? defaultGetCdnId;\n const failedSet = new Set(failed);\n return tracks.filter((track) => !failedSet.has(getCdnId(track.url)));\n}\n\n/**\n * Capability constraint — a *hard* filter (constraints pre-pass), shared by\n * video and audio. Removes renditions this environment can't decode, probed via\n * the injected `canPlayTrack` (codec → `MediaSource.isTypeSupported`). Moving\n * the check here — before selection — means an unplayable variant (e.g. HEVC on\n * a browser without HEVC) is pruned upstream and never picked, instead of\n * surviving into the pipeline to fail late at `createSourceBuffer`. That late\n * throw stays as a defensive structural guarantee; with this constraint it\n * should rarely fire.\n *\n * Passes everything through when there's no `canPlayTrack` probe (a composition\n * that didn't wire it, or DOM-free tests). When it prunes *every* track (no\n * decodable rendition), the empty result is preserved (per `applyConstraints`)\n * — \"nothing playable,\" so the behavior clears the selection (no pick) and the\n * late `createSourceBuffer` check stays as the backstop.\n */\nfunction excludeUnplayableTracks<S extends SelectionKey, T extends SwitchableTrack>(\n tracks: readonly T[],\n { config }: SelectionRuleDeps<TrackSwitchingStateMap<S>, AnySlotMap, CapabilityConstraintConfig<S, T>>\n): readonly T[] {\n const canPlay = config.canPlayTrack;\n if (!canPlay) return tracks;\n return tracks.filter((track) => canPlay(track));\n}\n\n/**\n * Active-CDN scope — a soft filter, shared by video and audio. Narrows to the\n * highest-priority CDN in `cdnPriority` (owned by `deriveCdnPriority`) that\n * still has tracks, so every track type stays on one CDN. A redundant-streams\n * source lists the same renditions on multiple hosts; this keeps the pick on one\n * host rather than letting the ranker drift across them.\n *\n * \"Active\" is derived, not stored: constraints run before the rule chain, so a\n * failed CDN's tracks are already pruned by the time this runs — \"first CDN with\n * survivors\" *is* the active CDN, and it falls through to the next on failover\n * (and snaps back to the primary when it recovers). Content steering reorders\n * `cdnPriority`; this rule just honors the order.\n *\n * Soft-filter semantics: passes through when there's no `cdnPriority` signal/value\n * (no preference) or when nothing matches (`applyRules` skips an empty result).\n * Non-redundant sources have one CDN, so the narrow is a no-op.\n *\n * The CDN-id derivation defaults to origin-based `getCdnId`, overridable via the\n * `getCdnId` config — it must match the one `deriveCdnPriority` used to build\n * `cdnPriority`, or no track's CDN would ever equal an entry.\n */\nfunction preferActiveCdn<S extends SelectionKey, T extends SwitchableTrack>(\n tracks: readonly T[],\n { state, config }: SelectionRuleDeps<CdnScopeStateMap<S>, AnySlotMap, CdnRuleConfig<S, T>>\n): readonly T[] {\n const cdnPriority = state.cdnPriority?.get();\n if (!cdnPriority?.length) return tracks;\n const getCdnId = config.getCdnId ?? defaultGetCdnId;\n for (const cdn of cdnPriority) {\n const tracksUsingCdn = tracks.filter((track) => getCdnId(track.url) === cdn);\n if (tracksUsingCdn.length) return tracksUsingCdn;\n }\n return tracks;\n}\n\n/**\n * Bandwidth ranking — the terminal sort, shared by video and audio. Orders by\n * the throughput estimate: tracks within the bandwidth threshold first\n * (fitting), highest bitrate first; then over-threshold tracks, least-over\n * first. The head is the best-quality track that fits, falling back to the\n * smallest over-throughput track when nothing fits.\n *\n * Hysteresis without temporal state: the current track's effective bitrate is\n * boosted by `upgradeMargin` in the fitting sort, so a higher track only\n * outranks it once it clears `current.bitrate * upgradeMargin` (no flapping on\n * marginal bandwidth gains). Downgrades fall out for free — a current track\n * over the threshold isn't in the fitting set to be boosted, so the best fit (a\n * downgrade) wins immediately. Equal-bitrate tracks break by resolution (higher\n * `width × height` first), so an equal-bitrate ladder never picks a lower-\n * quality rendition by manifest order; audio tracks carry no dimensions, so\n * they area-compare equal and a stable sort keeps their candidate order (e.g.\n * same-bitrate language variants). Early-bail skips this rule when a prior one\n * narrowed to a single track, so the estimate is neither read nor subscribed\n * while that holds.\n */\nfunction rankByBandwidth<S extends SelectionKey, T extends SwitchableTrack>(\n tracks: readonly T[],\n { state, config }: SelectionRuleDeps<BandwidthRankerStateMap<S>, AnySlotMap, BandwidthRankerConfig<S, T>>\n): readonly T[] {\n const safetyMargin = config.quality?.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;\n const upgradeMargin = config.quality?.upgradeMargin ?? DEFAULT_QUALITY_CONFIG.upgradeMargin;\n const initialBandwidth = config.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH;\n const bandwidthConfig: BandwidthConfig = { ...DEFAULT_BANDWIDTH_CONFIG, ...config.bandwidth };\n if (!state.bandwidthState) {\n console.debug(\n '[track-switching] rankByBandwidth: no bandwidthState signal in composition; ranking on initialBandwidth'\n );\n }\n const threshold = getBandwidthEstimate(state.bandwidthState?.get(), initialBandwidth, bandwidthConfig) * safetyMargin;\n const currentId = state[config.selectionKey].get();\n const bitrate = (track: T) => track.bandwidth ?? 0;\n // Boost the current track's sort weight by upgradeMargin (fitting set only) so\n // an upgrade must clear current.bitrate * upgradeMargin to outrank it.\n const rank = (track: T) => (track.id === currentId ? bitrate(track) * upgradeMargin : bitrate(track));\n // Equal bitrate → prefer higher resolution (width × height), so an\n // equal-bitrate ladder doesn't pick a lower-quality rendition by manifest\n // order. Audio tracks carry no dimensions, so they area-compare equal and\n // keep candidate order (stable sort).\n const fitting = tracks\n .filter((track) => bitrate(track) <= threshold)\n .sort((a, b) => rank(b) - rank(a) || resolutionArea(b) - resolutionArea(a));\n const over = tracks\n .filter((track) => bitrate(track) > threshold)\n .sort((a, b) => bitrate(a) - bitrate(b) || resolutionArea(b) - resolutionArea(a));\n return [...fitting, ...over];\n}\n\n/**\n * Default final pick: the chain head. `applyRules` never narrows to nothing and\n * early-bails to a single survivor, so video and audio always converge to a\n * track and the head is the pick.\n */\nfunction selectChainHead<T extends SwitchableTrack>(candidates: readonly T[]): string {\n return candidates[0]!.id;\n}\n\n/**\n * State the text terminal reads: the lifecycle map plus an *optional*\n * `userTextTrackSelection` — the standing user intent. `Partial<TextTrack>` is an\n * explicit pick (language-based), `'off'` is explicit no-captions, `undefined` is\n * auto (no preference). The slot exists only when the composition materializes it\n * (`shareSignals`); the terminal reads it defensively and treats absence as auto.\n *\n * Unlike `user*TrackSelection` for video/audio, this carries the `'off'` sentinel\n * and feeds the terminal pick (not the shared `filterByUserSelection`) — text is\n * the only type whose selection is legitimately optional, so the off/auto logic\n * lives in one text-specific place rather than widening the shared filter.\n */\ntype TextSelectionStateMap = TrackSwitchingStateMap<'selectedTextTrackId'> & {\n userTextTrackSelection?: ReadonlySignal<Partial<TextTrack> | 'off' | undefined>;\n};\n\n/** Config the text terminal reads: the base config plus the opt-in default policy. */\ntype TextTerminalConfig = TrackSwitchingConfig<'selectedTextTrackId', TextTrackCandidate> & TextSelectionConfig;\n\n/**\n * Terminal pick for text — the `resolveSelection` the text variant supplies.\n * Resolves the standing `userTextTrackSelection` intent against the chain's\n * survivors (already CDN-failover-pruned and active-CDN-scoped):\n *\n * - `'off'` → no selection (clear the slot). Sticky through re-evaluation, so a\n * live refresh or failover re-run can't re-assert a default.\n * - explicit `Partial<TextTrack>` → narrow to the match (language-based). A\n * stale pick whose match is gone (e.g. the language dropped on a source\n * change) falls through to the default policy.\n * - auto (`undefined`) → the opt-in default policy (`preferredSubtitleLanguage`\n * → `DEFAULT=YES + AUTOSELECT=YES` → none), shared with `pickTextTrack`.\n *\n * Returning `undefined` is a real outcome (captions are opt-in), which is why the\n * text variant relies on `setupTrackSwitching`'s no-selection seam.\n */\nfunction pickResolvedTextTrack<T extends TextTrackCandidate>(\n candidates: readonly T[],\n { state, config }: SelectionRuleDeps<TextSelectionStateMap, AnySlotMap, TextTerminalConfig>\n): string | undefined {\n const intent = state.userTextTrackSelection?.get();\n if (intent === 'off') return undefined;\n if (intent) {\n // The stored intent is a `Partial<TextTrack>`; cast to the candidate's own\n // partial shape so the generic `matchesPartialTrack` accepts it (every field\n // it carries — language, forced — exists on the candidate too).\n const matched = candidates.filter((track) => matchesPartialTrack(track, intent as Partial<T>));\n if (matched.length) return matched[0]!.id;\n }\n return pickTextTrackFromTracks(candidates, config);\n}\n\n// `context` is the composition's context map, threaded in by each variant's\n// rest-spread and typed as the generic slot-map shape (`AnySlotMap`). It can't\n// be a typed param on the `defineBehavior` setup without widening `ContextMap`\n// to its constraint and forcing the slot required, so the variants forward it\n// untyped via the rest and it lands here — absent on direct setup calls, and\n// passed straight through to the rules (which don't read it yet).\nexport function setupTrackSwitching<\n S extends SelectionKey,\n T extends SwitchableTrack,\n C extends TrackSwitchingConfig<S, T>,\n>(deps: { state: TrackSwitchingStateMap<S>; context?: AnySlotMap; config: C }) {\n const { state, config } = deps;\n const { selectionKey, getTracks, rules, resolveSelection = selectChainHead } = config;\n\n const derivedStateSignal = computed(() =>\n isResolvedPresentation(state.presentation.get())\n ? ('presentation-resolved' as const)\n : ('presentation-unresolved' as const)\n );\n\n // The playable candidate set — the tracks the rule chain gets to pick from,\n // derived *outside* the reaction. The hard-constraints pre-pass (capability\n // probing, CDN-failover cooldown) narrows the type's tracks before the chain\n // runs. Because this is a `computed`, a constraint's own signal reads (e.g.\n // `cdnHealth`) are tracked here, so when the playable set changes — a new\n // source, or a *dynamic* constraint like a CDN entering cooldown — the effect\n // re-picks. With no constraints configured this is just the type's tracks\n // while a presentation is resolved.\n //\n // The `equals` gates notification on the *set of track ids*, not array\n // identity: a live playlist refresh swaps in a new presentation object with\n // the same variant tracks, and a constraint's inputs can churn without\n // changing which tracks survive. In both cases the playable set is unchanged,\n // so the reaction must not re-fire (the rule chain still re-runs on its own\n // inputs — bandwidth, user selection). Same intent as `equalsById`, for the\n // track list.\n const candidateSet = computed<readonly T[]>(\n () => {\n const presentation = state.presentation.get();\n if (!isResolvedPresentation(presentation)) return [];\n return applyConstraints(config.constraints ?? [], getTracks(presentation), deps);\n },\n { equals: (a, b) => a.length === b.length && a.every((track) => b.some((other) => other.id === track.id)) }\n );\n\n return createMachineReactor({\n initial: 'presentation-unresolved',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'presentation-unresolved': {},\n 'presentation-resolved': {\n // Canonical cleanup-binds-to-setup: the selection signal's valid\n // lifespan is exactly 'presentation-resolved'. Clear fires on exit,\n // covering both src unload and behavior destroy.\n entry: () => () => state[selectionKey].set(undefined),\n effects: [\n () => {\n // Reactive read: subscribes the reaction to the candidate set, so a\n // new presentation — or a constraint pruning it — re-fires this and\n // re-picks.\n const tracks = candidateSet.get();\n\n // Empty candidate set — two shapes, told apart by whether the type\n // has any tracks at all:\n // - The type has no tracks (e.g. a video-only source's absent\n // audio): legitimate, nothing to pick or clear.\n // - The type HAS tracks but the hard-constraints pre-pass pruned\n // every one (every rendition undecodable, or every CDN in\n // failover cooldown): no playable rendition. Clear the selection\n // so a pick made earlier — e.g. under the initial mp4 label,\n // before resolve-track relabeled the type to a non-fMP4\n // container — can't linger as a now-unplayable selection and\n // silently stall the pipeline.\n // The `console.error` is a placeholder until the planned error\n // behaviors surface \"nothing playable\" as observable state.\n if (!tracks.length) {\n const presentation = peek(state.presentation);\n const hasTracksOfType = isResolvedPresentation(presentation) && getTracks(presentation).length > 0;\n if (hasTracksOfType) {\n console.error(\n `[track-switching] every ${selectionKey} candidate was filtered out by constraints; clearing selection`\n );\n state[selectionKey].set(undefined);\n }\n return;\n }\n\n // The whole deps object passes straight through to every rule in the\n // variant-supplied chain (state + config from the behavior; context\n // threaded in by the variant's rest-spread). Typed against the base\n // config — each rule re-declares the extra fields it reads as\n // optional, and the concrete `C` is assignable to the base.\n const candidates = applyRules<T, TrackSwitchingStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>(\n rules,\n tracks,\n deps\n );\n\n // applyRules early-bails to a single survivor and never narrows to\n // nothing (a soft filter that would empty the set falls through), so\n // the pick is just the head. An empty result means a rule misbehaved\n // — applyRules is supposed to account for those cases, so surface it.\n if (!candidates.length) {\n console.error('[track-switching] applyRules returned no candidates');\n return;\n }\n // Map survivors to the final id. Defaults to the chain head; a\n // variant with optional selection (text) may resolve to `undefined`,\n // which clears the slot (e.g. explicit off, opt-in decline).\n // No change-guard needed: the slot uses default (Object.is) equality,\n // so re-setting the same value is a no-op (no notify, no re-fire).\n state[selectionKey].set(resolveSelection(candidates, deps));\n },\n ],\n },\n },\n });\n}\n\n// ============================================================================\n// Variant: switchVideoTrack — bandwidth-driven ABR\n// ============================================================================\n\n/**\n * Manage `selectedVideoTrackId`: pick a default on src load, dynamically\n * adjust based on bandwidth, clear on src unload. Honors\n * `userVideoTrackSelection` as a partial-track constraint on candidates;\n * short-circuits ABR when the constraint narrows to a single track.\n *\n * @example\n * const reactor = switchVideoTrack.setup({ state });\n */\nexport const switchVideoTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedVideoTrackId'],\n contextKeys: [],\n setup: ({\n state,\n config,\n ...otherProps\n }: {\n state: TrackSwitchingStateMap<'selectedVideoTrackId'>;\n config?: SwitchVideoTrackConfig;\n }) =>\n setupTrackSwitching({\n ...otherProps,\n state,\n config: {\n ...config,\n selectionKey: 'selectedVideoTrackId',\n userSelectionKey: 'userVideoTrackSelection',\n getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[],\n constraints: [excludeFailedCdns, excludeUnplayableTracks],\n rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],\n },\n }),\n});\n\n// ============================================================================\n// Variant: switchAudioTrack — bandwidth-ranked (shared ranker)\n// ============================================================================\n\n/**\n * Manage `selectedAudioTrackId`: pick a default on src load, narrow by\n * `userAudioTrackSelection` filter, re-pick on filter change, clear on\n * src unload.\n *\n * Mid-stream flush on language switch is handled by the segment-loader's\n * `planTasks` (see `playback/actors/dom/segment-loader.ts`) — not this\n * behavior. Same split as the video pipeline: slot owner writes; loader\n * orchestrates segment + flush plans.\n *\n * @example\n * const reactor = switchAudioTrack.setup({ state });\n */\nexport const switchAudioTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedAudioTrackId'],\n contextKeys: [],\n setup: ({\n state,\n config,\n ...otherProps\n }: {\n state: TrackSwitchingStateMap<'selectedAudioTrackId'>;\n // Shares the video config shape so the engine config spreads through (CDN\n // derivation + any future cross-cutting fields).\n config?: SwitchVideoTrackConfig;\n }) =>\n setupTrackSwitching({\n ...otherProps,\n state,\n config: {\n // Spread engine config so cross-cutting fields (`getCdnId`, future shared\n // tuning) flow through like they do for video, then override the per-type\n // wiring. Video-only ABR tuning (`quality`/`bandwidth`/`initialBandwidth`)\n // rides along into the shared `rankByBandwidth` too; harmless since audio\n // has no `bandwidthState` to act on it and the ranker always yields a pick.\n // FOLLOW-UP: a shared config type for the genuinely cross-cutting fields\n // would keep video-only tuning out of audio entirely (CJP).\n ...config,\n selectionKey: 'selectedAudioTrackId',\n userSelectionKey: 'userAudioTrackSelection',\n getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[],\n constraints: [excludeFailedCdns, excludeUnplayableTracks],\n rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],\n },\n }),\n});\n\n// ============================================================================\n// Variant: switchTextTrack — intent-resolved, optional selection\n// ============================================================================\n\n/**\n * Config for `switchTextTrack` — the opt-in default policy\n * (`preferredSubtitleLanguage` / `includeForcedTracks` / `enableDefaultTrack`,\n * via `TextSelectionConfig`) read by the terminal when the user has no standing\n * intent, plus the `getCdnId` override shared with the CDN constraint + scope.\n */\nexport interface SwitchTextTrackConfig extends TextSelectionConfig {\n /** Override CDN-id derivation (shared by the failed-CDN constraint + active-CDN scope). */\n getCdnId?: GetCdnId;\n}\n\n/**\n * Manage `selectedTextTrackId` as the single-writer **output** of standing user\n * intent (`userTextTrackSelection`) resolved against the playable, CDN-scoped\n * text renditions: clear on src unload; re-resolve when a CDN fails or recovers.\n *\n * Unlike video/audio, the selection is *optional* — captions are opt-in and the\n * user can turn them off — so the chain skips the bandwidth ranker and the shared\n * user-selection filter, and supplies a text-specific terminal\n * (`pickResolvedTextTrack`) that may resolve to no-selection via\n * `setupTrackSwitching`'s `resolveSelection` seam. Constraints are failed-CDN only\n * (`excludeUnplayableTracks`/`canPlayTrack` is MSE-based — the wrong probe for\n * text, whose playability is SPF-parser support); the active-CDN scope co-locates\n * captions with the surviving CDN on failover.\n *\n * @example\n * const reactor = switchTextTrack.setup({ state, config: { preferredSubtitleLanguage: 'en' } });\n */\nexport const switchTextTrack = defineBehavior({\n stateKeys: ['presentation', 'selectedTextTrackId'],\n contextKeys: [],\n setup: ({\n state,\n config,\n ...otherProps\n }: {\n state: TrackSwitchingStateMap<'selectedTextTrackId'>;\n config?: SwitchTextTrackConfig;\n }) =>\n // Explicit type args pin the candidate type to `TextTrackCandidate`. Video and\n // audio let it infer to the `SwitchableTrack` constraint (harmless — every\n // rule is assignable up to it), but the text terminal needs the narrower type\n // (it reads text-only fields), so it's named here rather than inferred.\n setupTrackSwitching<'selectedTextTrackId', TextTrackCandidate, TextTerminalConfig & SwitchTextTrackConfig>({\n ...otherProps,\n state,\n config: {\n ...config,\n selectionKey: 'selectedTextTrackId',\n getTracks: (presentation) => getTracksByType(presentation, 'text') as readonly TextTrackCandidate[],\n constraints: [excludeFailedCdns],\n rules: [preferActiveCdn],\n resolveSelection: pickResolvedTextTrack,\n },\n }),\n});\n"],"mappings":"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"}
@@ -0,0 +1,158 @@
1
+ import { maxResolutionToPixelArea, pickTrackUnderPixelArea } from "../../../media/primitives/select-tracks.js";
2
+ import { createBackgroundVideoEngine } from "./engine.js";
3
+ //#region src/playback/engines/background-video/adapter.ts
4
+ const backgroundVideoMediaDefaultProps = {
5
+ src: "",
6
+ preload: "auto",
7
+ loop: true,
8
+ muted: true,
9
+ autoplay: true,
10
+ maxResolution: void 0
11
+ };
12
+ /**
13
+ * Mixin that adds the background-video SPF playback engine to any
14
+ * base class.
15
+ *
16
+ * Implements the WHATWG HTML media element contract (`src`, `preload`,
17
+ * `loop`, `muted`, `autoplay`, `play()`) so it can be dropped in anywhere a
18
+ * media element API is expected. Compared to `SimpleHlsMediaMixin`, this
19
+ * variant:
20
+ *
21
+ * - exposes `loop`, `muted`, and `autoplay` as adapter-owned native
22
+ * passthroughs, all defaulting to `true` — the use case is silent
23
+ * autoplay-looping video, so muted + autoplay satisfy browser autoplay
24
+ * policies and loop is the defining behavior;
25
+ * - drives the underlying engine with the background-video
26
+ * composition (single-rendition, video-only, autoplay-from-construction).
27
+ *
28
+ * A new engine is created on every src assignment — this fully tears down
29
+ * all state, SourceBuffers, and in-flight requests from the previous
30
+ * source before the next one begins. The media element reference is
31
+ * preserved across src changes and re-applied to the new engine
32
+ * automatically.
33
+ *
34
+ * @example
35
+ * class BackgroundVideoMedia extends BackgroundVideoMediaMixin(HTMLVideoElementHost) {}
36
+ *
37
+ * const media = new BackgroundVideoMedia();
38
+ * media.attach(document.querySelector('video'));
39
+ * media.src = 'https://stream.mux.com/abc123.m3u8';
40
+ * media.play();
41
+ */
42
+ function BackgroundVideoMediaMixin(BaseClass) {
43
+ class BackgroundVideoMediaImpl extends BaseClass {
44
+ #engine;
45
+ #config;
46
+ #signals;
47
+ #preload = backgroundVideoMediaDefaultProps.preload;
48
+ #loop = backgroundVideoMediaDefaultProps.loop;
49
+ #muted = backgroundVideoMediaDefaultProps.muted;
50
+ #autoplay = backgroundVideoMediaDefaultProps.autoplay;
51
+ #maxResolution;
52
+ /** Pending loadstart listener from a deferred play() retry, if any. */
53
+ #loadstartListener = null;
54
+ constructor(...args) {
55
+ super(...args);
56
+ const { config } = args?.[0] ?? {};
57
+ this.#config = config;
58
+ this.#maxResolution = config?.maxResolution;
59
+ this.#engine = this.#createEngine();
60
+ }
61
+ get engine() {
62
+ return this.#engine;
63
+ }
64
+ attach(mediaElement) {
65
+ super.attach?.(mediaElement);
66
+ mediaElement.loop = this.#loop;
67
+ mediaElement.muted = this.#muted;
68
+ mediaElement.autoplay = this.#autoplay;
69
+ this.#signals.context.mediaElement.set(mediaElement);
70
+ }
71
+ detach() {
72
+ this.#cancelPendingPlay();
73
+ this.#signals.context.mediaElement.set(void 0);
74
+ super.detach?.();
75
+ }
76
+ destroy() {
77
+ this.#cancelPendingPlay();
78
+ this.#engine.destroy();
79
+ }
80
+ get preload() {
81
+ return this.#preload;
82
+ }
83
+ set preload(_value) {}
84
+ get loop() {
85
+ return this.#loop;
86
+ }
87
+ set loop(_value) {}
88
+ get muted() {
89
+ return this.#muted;
90
+ }
91
+ set muted(_value) {}
92
+ get autoplay() {
93
+ return this.#autoplay;
94
+ }
95
+ set autoplay(_value) {}
96
+ get maxResolution() {
97
+ return this.#maxResolution;
98
+ }
99
+ /**
100
+ * Set the cap. Accepts `"720p"` / `"1080p"` etc., a bare number
101
+ * (interpreted as pixel area), or `undefined` to clear. Unrecognized
102
+ * values are treated as no cap.
103
+ */
104
+ set maxResolution(value) {
105
+ if (value === this.#maxResolution) return;
106
+ this.#maxResolution = value;
107
+ }
108
+ get src() {
109
+ return this.#signals.state.presentation.get()?.url ?? "";
110
+ }
111
+ set src(value) {
112
+ this.#cancelPendingPlay();
113
+ if (value) this.#signals.state.presentation.set({ url: value });
114
+ else this.#signals.state.presentation.set(void 0);
115
+ }
116
+ async play() {
117
+ const mediaElement = this.#signals.context.mediaElement.get();
118
+ if (!mediaElement) return Promise.reject(/* @__PURE__ */ new Error("BackgroundVideoMediaElement: no media element attached"));
119
+ try {
120
+ return await mediaElement.play();
121
+ } catch (err) {
122
+ if (this.src) return new Promise((resolve, reject) => {
123
+ const listener = () => {
124
+ this.#loadstartListener = null;
125
+ mediaElement.play().then(resolve, reject);
126
+ };
127
+ this.#loadstartListener = listener;
128
+ mediaElement.addEventListener("loadstart", listener, { once: true });
129
+ });
130
+ throw err;
131
+ }
132
+ }
133
+ #createEngine() {
134
+ const adapterPicker = (presentation) => {
135
+ return pickTrackUnderPixelArea((presentation.selectionSets?.find((s) => s.type === "video"))?.switchingSets[0]?.tracks ?? [], maxResolutionToPixelArea(this.#maxResolution))?.id;
136
+ };
137
+ return createBackgroundVideoEngine({
138
+ picker: adapterPicker,
139
+ ...this.#config,
140
+ onSignalsReady: (signals) => {
141
+ this.#signals = signals;
142
+ }
143
+ });
144
+ }
145
+ #cancelPendingPlay() {
146
+ if (!this.#loadstartListener) return;
147
+ this.#signals.context.mediaElement.get()?.removeEventListener("loadstart", this.#loadstartListener);
148
+ this.#loadstartListener = null;
149
+ }
150
+ }
151
+ return BackgroundVideoMediaImpl;
152
+ }
153
+ /** Standalone SPF background-video adapter with no base class. */
154
+ var BackgroundVideoMediaElement = class extends BackgroundVideoMediaMixin(class {}) {};
155
+ //#endregion
156
+ export { BackgroundVideoMediaElement, BackgroundVideoMediaMixin, backgroundVideoMediaDefaultProps };
157
+
158
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","names":["#config","#maxResolution","#engine","#createEngine","#loop","#muted","#autoplay","#signals","#cancelPendingPlay","#preload","#loadstartListener"],"sources":["../../../../../src/playback/engines/background-video/adapter.ts"],"sourcesContent":["import type { Constructor, MixinReturn } from '@videojs/utils/types';\nimport type { Composition } from '../../../core/composition/create-composition';\nimport {\n maxResolutionToPixelArea,\n pickTrackUnderPixelArea,\n type TrackPicker,\n} from '../../../media/primitives/select-tracks';\nimport type { VideoSelectionSet } from '../../../media/types';\nimport {\n type BackgroundVideoEngineConfig,\n type BackgroundVideoEngineContext,\n type BackgroundVideoEngineSignals,\n type BackgroundVideoEngineState,\n createBackgroundVideoEngine,\n} from './engine';\n\nexport interface BackgroundVideoMediaProps {\n src: string;\n preload: '' | 'none' | 'metadata' | 'auto';\n loop: boolean;\n muted: boolean;\n autoplay: boolean;\n maxResolution: string | number | undefined;\n}\n\nexport const backgroundVideoMediaDefaultProps: BackgroundVideoMediaProps = {\n src: '',\n preload: 'auto',\n loop: true,\n muted: true,\n autoplay: true,\n maxResolution: undefined,\n};\n\nexport interface BackgroundVideoMediaAPI extends BackgroundVideoMediaProps {\n readonly engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n attach(mediaElement: HTMLMediaElement): void;\n detach(): void;\n destroy(): void;\n play(): Promise<void>;\n}\n\n/**\n * Mixin that adds the background-video SPF playback engine to any\n * base class.\n *\n * Implements the WHATWG HTML media element contract (`src`, `preload`,\n * `loop`, `muted`, `autoplay`, `play()`) so it can be dropped in anywhere a\n * media element API is expected. Compared to `SimpleHlsMediaMixin`, this\n * variant:\n *\n * - exposes `loop`, `muted`, and `autoplay` as adapter-owned native\n * passthroughs, all defaulting to `true` — the use case is silent\n * autoplay-looping video, so muted + autoplay satisfy browser autoplay\n * policies and loop is the defining behavior;\n * - drives the underlying engine with the background-video\n * composition (single-rendition, video-only, autoplay-from-construction).\n *\n * A new engine is created on every src assignment — this fully tears down\n * all state, SourceBuffers, and in-flight requests from the previous\n * source before the next one begins. The media element reference is\n * preserved across src changes and re-applied to the new engine\n * automatically.\n *\n * @example\n * class BackgroundVideoMedia extends BackgroundVideoMediaMixin(HTMLVideoElementHost) {}\n *\n * const media = new BackgroundVideoMedia();\n * media.attach(document.querySelector('video'));\n * media.src = 'https://stream.mux.com/abc123.m3u8';\n * media.play();\n */\nexport function BackgroundVideoMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {\n class BackgroundVideoMediaImpl extends BaseClass {\n #engine: Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext>;\n #config: BackgroundVideoEngineConfig;\n #signals!: BackgroundVideoEngineSignals;\n #preload: '' | 'none' | 'metadata' | 'auto' = backgroundVideoMediaDefaultProps.preload;\n #loop: boolean = backgroundVideoMediaDefaultProps.loop;\n #muted: boolean = backgroundVideoMediaDefaultProps.muted;\n #autoplay: boolean = backgroundVideoMediaDefaultProps.autoplay;\n #maxResolution: string | number | undefined;\n\n /** Pending loadstart listener from a deferred play() retry, if any. */\n #loadstartListener: (() => void) | null = null;\n\n constructor(...args: any[]) {\n super(...args);\n\n const { config } = args?.[0] ?? {};\n this.#config = config;\n\n this.#maxResolution = config?.maxResolution;\n this.#engine = this.#createEngine();\n }\n\n get engine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n return this.#engine;\n }\n\n // -------------------------------------------------------------------------\n // Media element lifecycle\n // -------------------------------------------------------------------------\n\n attach(mediaElement: HTMLMediaElement): void {\n super.attach?.(mediaElement);\n // Apply adapter-owned native props before the engine takes over —\n // the underlying element needs `loop` / `muted` / `autoplay` set for\n // the use case's autoplay-looping semantics.\n mediaElement.loop = this.#loop;\n mediaElement.muted = this.#muted;\n mediaElement.autoplay = this.#autoplay;\n\n this.#signals.context.mediaElement.set(mediaElement);\n }\n\n detach(): void {\n this.#cancelPendingPlay();\n this.#signals.context.mediaElement.set(undefined);\n super.detach?.();\n }\n\n destroy(): void {\n this.#cancelPendingPlay();\n this.#engine.destroy();\n }\n\n // -------------------------------------------------------------------------\n // preload — synchronous IDL attribute (WHATWG §4.8.11.2)\n // -------------------------------------------------------------------------\n\n get preload(): '' | 'none' | 'metadata' | 'auto' {\n return this.#preload;\n }\n\n set preload(_value: '' | 'none' | 'metadata' | 'auto') {\n // Noop for this phase\n }\n\n // -------------------------------------------------------------------------\n // loop / muted / autoplay — adapter-owned IDL attributes mirrored onto\n // the attached media element. The engine itself has no opinion on any\n // of them.\n // -------------------------------------------------------------------------\n\n get loop(): boolean {\n return this.#loop;\n }\n\n set loop(_value: boolean) {\n // Noop for this phase\n }\n\n get muted(): boolean {\n return this.#muted;\n }\n\n set muted(_value: boolean) {\n // Noop for this phase\n }\n\n get autoplay(): boolean {\n return this.#autoplay;\n }\n\n set autoplay(_value: boolean) {\n // Noop for this phase\n }\n\n // -------------------------------------------------------------------------\n // maxResolution — adapter-owned cap on the picked rendition. The engine's\n // closure picker (see `#createEngine`) reads this field at pick time, so\n // setter writes take effect on the next `presentation-resolved` transition\n // without an engine rebuild.\n // -------------------------------------------------------------------------\n\n get maxResolution(): string | number | undefined {\n return this.#maxResolution;\n }\n\n /**\n * Set the cap. Accepts `\"720p\"` / `\"1080p\"` etc., a bare number\n * (interpreted as pixel area), or `undefined` to clear. Unrecognized\n * values are treated as no cap.\n */\n set maxResolution(value: string | number | undefined) {\n if (value === this.#maxResolution) return;\n this.#maxResolution = value;\n }\n\n // -------------------------------------------------------------------------\n // src — synchronous IDL attribute (WHATWG §4.8.11.2)\n // Each assignment destroys the current engine and starts a fresh one,\n // matching the browser's load algorithm reset on src change.\n // -------------------------------------------------------------------------\n\n get src(): string {\n return this.#signals.state.presentation.get()?.url ?? '';\n }\n\n set src(value: string) {\n this.#cancelPendingPlay();\n\n if (value) {\n this.#signals.state.presentation.set({ url: value });\n } else {\n this.#signals.state.presentation.set(undefined);\n }\n }\n\n // -------------------------------------------------------------------------\n // play() — WHATWG §4.8.11.8\n // Delegates to the attached media element's native play().\n // -------------------------------------------------------------------------\n\n async play(): Promise<void> {\n const mediaElement = this.#signals.context.mediaElement.get();\n if (!mediaElement) {\n return Promise.reject(new Error('BackgroundVideoMediaElement: no media element attached'));\n }\n\n try {\n return await mediaElement.play();\n } catch (err) {\n // If we have a pending HLS source, the rejection may be because MSE\n // hasn't attached a blob URL yet. Wait for loadstart (src assigned\n // by MSE setup) and retry once.\n if (this.src) {\n return new Promise<void>((resolve, reject) => {\n const listener = () => {\n this.#loadstartListener = null;\n mediaElement.play().then(resolve, reject);\n };\n this.#loadstartListener = listener;\n mediaElement.addEventListener('loadstart', listener, { once: true });\n });\n }\n throw err;\n }\n }\n\n // -------------------------------------------------------------------------\n // Private\n // -------------------------------------------------------------------------\n\n #createEngine(): Composition<BackgroundVideoEngineState, BackgroundVideoEngineContext> {\n const adapterPicker: TrackPicker = (presentation) => {\n const videoSet = presentation.selectionSets?.find((s) => s.type === 'video') as VideoSelectionSet | undefined;\n const tracks = videoSet?.switchingSets[0]?.tracks ?? [];\n return pickTrackUnderPixelArea(tracks, maxResolutionToPixelArea(this.#maxResolution))?.id;\n };\n\n return createBackgroundVideoEngine({\n picker: adapterPicker,\n ...this.#config,\n onSignalsReady: (signals) => {\n this.#signals = signals;\n },\n });\n }\n\n #cancelPendingPlay(): void {\n if (!this.#loadstartListener) return;\n const mediaElement = this.#signals.context.mediaElement.get();\n mediaElement?.removeEventListener('loadstart', this.#loadstartListener);\n this.#loadstartListener = null;\n }\n }\n\n return BackgroundVideoMediaImpl as unknown as MixinReturn<Base, BackgroundVideoMediaAPI>;\n}\n\n/** Standalone SPF background-video adapter with no base class. */\nexport class BackgroundVideoMediaElement extends BackgroundVideoMediaMixin(class {}) {}\n"],"mappings":"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"}