@blockcast/mmt-transport 0.2.2 → 0.3.0

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 (167) hide show
  1. package/README.md +44 -58
  2. package/dist/abr-controller.d.ts +34 -4
  3. package/dist/abr-controller.d.ts.map +1 -1
  4. package/dist/abr-controller.js +66 -17
  5. package/dist/abr-controller.js.map +1 -1
  6. package/dist/amt-gateway.d.ts.map +1 -1
  7. package/dist/amt-gateway.js +3 -4
  8. package/dist/amt-gateway.js.map +1 -1
  9. package/dist/driad-discovery.d.ts.map +1 -1
  10. package/dist/driad-discovery.js +3 -6
  11. package/dist/driad-discovery.js.map +1 -1
  12. package/dist/endpoint-config.d.ts +68 -0
  13. package/dist/endpoint-config.d.ts.map +1 -1
  14. package/dist/endpoint-config.js +118 -3
  15. package/dist/endpoint-config.js.map +1 -1
  16. package/dist/index.d.ts +10 -8
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/mmt-transport.no-modules.js +881 -60
  21. package/dist/mmt-transport.no-modules.js.map +4 -4
  22. package/dist/moqt-priority.d.ts +39 -0
  23. package/dist/moqt-priority.d.ts.map +1 -0
  24. package/dist/moqt-priority.js +41 -0
  25. package/dist/moqt-priority.js.map +1 -0
  26. package/dist/multicast-catalog-policy.d.ts +96 -0
  27. package/dist/multicast-catalog-policy.d.ts.map +1 -0
  28. package/dist/multicast-catalog-policy.js +660 -0
  29. package/dist/multicast-catalog-policy.js.map +1 -0
  30. package/dist/multicast-orchestrator.d.ts +24 -0
  31. package/dist/multicast-orchestrator.d.ts.map +1 -1
  32. package/dist/multicast-orchestrator.js +102 -9
  33. package/dist/multicast-orchestrator.js.map +1 -1
  34. package/dist/multicast-signaling-reconciler.d.ts +37 -0
  35. package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
  36. package/dist/multicast-signaling-reconciler.js +161 -0
  37. package/dist/multicast-signaling-reconciler.js.map +1 -0
  38. package/dist/multicast-subscription-pool.d.ts.map +1 -1
  39. package/dist/multicast-subscription-pool.js +37 -10
  40. package/dist/multicast-subscription-pool.js.map +1 -1
  41. package/dist/multicast-tier.d.ts +19 -0
  42. package/dist/multicast-tier.d.ts.map +1 -1
  43. package/dist/multicast-tier.js +21 -4
  44. package/dist/multicast-tier.js.map +1 -1
  45. package/dist/no-modules.d.ts +40 -3
  46. package/dist/no-modules.d.ts.map +1 -1
  47. package/dist/no-modules.js +30 -3
  48. package/dist/no-modules.js.map +1 -1
  49. package/dist/packet-batch.d.ts +13 -0
  50. package/dist/packet-batch.d.ts.map +1 -0
  51. package/dist/packet-batch.js +14 -0
  52. package/dist/packet-batch.js.map +1 -0
  53. package/dist/stall-detector.d.ts +10 -0
  54. package/dist/stall-detector.d.ts.map +1 -1
  55. package/dist/stall-detector.js +21 -0
  56. package/dist/stall-detector.js.map +1 -1
  57. package/dist/takeover.d.ts +3 -0
  58. package/dist/takeover.d.ts.map +1 -1
  59. package/dist/takeover.js +35 -2
  60. package/dist/takeover.js.map +1 -1
  61. package/dist/transport-manager.d.ts +21 -17
  62. package/dist/transport-manager.d.ts.map +1 -1
  63. package/dist/transport-manager.js +63 -29
  64. package/dist/transport-manager.js.map +1 -1
  65. package/dist/types.d.ts +98 -123
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js +12 -50
  68. package/dist/types.js.map +1 -1
  69. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  70. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  71. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  72. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  73. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  74. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  75. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  76. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  77. package/node_modules/@blockcast/mmt-base/README.md +134 -0
  78. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  79. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  80. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  81. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  82. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  83. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  84. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  85. package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  86. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  87. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  88. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  89. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  90. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  91. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  92. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  93. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  94. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  95. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  96. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  97. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  98. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  99. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  100. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  101. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  102. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  103. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  104. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  105. package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  106. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  107. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  108. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  109. package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  110. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  111. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  112. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  113. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  114. package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  115. package/package.json +13 -6
  116. package/src/abr-controller.ts +81 -17
  117. package/src/amt-gateway.ts +3 -5
  118. package/src/driad-discovery.ts +3 -6
  119. package/src/endpoint-config.ts +157 -3
  120. package/src/index.ts +54 -33
  121. package/src/moqt-priority.ts +51 -0
  122. package/src/multicast-catalog-policy.ts +753 -0
  123. package/src/multicast-orchestrator.ts +114 -10
  124. package/src/multicast-signaling-reconciler.ts +156 -0
  125. package/src/multicast-subscription-pool.ts +46 -11
  126. package/src/multicast-tier.ts +30 -3
  127. package/src/no-modules.ts +54 -2
  128. package/src/packet-batch.ts +17 -0
  129. package/src/stall-detector.ts +37 -0
  130. package/src/takeover.ts +41 -3
  131. package/src/transport-manager.ts +64 -34
  132. package/src/types.ts +103 -160
  133. package/dist/endpoint-config.test.d.ts +0 -2
  134. package/dist/endpoint-config.test.d.ts.map +0 -1
  135. package/dist/endpoint-config.test.js +0 -39
  136. package/dist/endpoint-config.test.js.map +0 -1
  137. package/dist/fec-client.d.ts +0 -442
  138. package/dist/fec-client.d.ts.map +0 -1
  139. package/dist/fec-client.js +0 -789
  140. package/dist/fec-client.js.map +0 -1
  141. package/dist/fec-client.test.d.ts +0 -8
  142. package/dist/fec-client.test.d.ts.map +0 -1
  143. package/dist/fec-client.test.js +0 -112
  144. package/dist/fec-client.test.js.map +0 -1
  145. package/dist/multicast-subscription-pool.test.d.ts +0 -2
  146. package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
  147. package/dist/multicast-subscription-pool.test.js +0 -108
  148. package/dist/multicast-subscription-pool.test.js.map +0 -1
  149. package/dist/multicast-tier.test.d.ts +0 -2
  150. package/dist/multicast-tier.test.d.ts.map +0 -1
  151. package/dist/multicast-tier.test.js +0 -75
  152. package/dist/multicast-tier.test.js.map +0 -1
  153. package/dist/stall-detector.test.d.ts +0 -2
  154. package/dist/stall-detector.test.d.ts.map +0 -1
  155. package/dist/stall-detector.test.js +0 -33
  156. package/dist/stall-detector.test.js.map +0 -1
  157. package/dist/transport-manager.test.d.ts +0 -2
  158. package/dist/transport-manager.test.d.ts.map +0 -1
  159. package/dist/transport-manager.test.js +0 -155
  160. package/dist/transport-manager.test.js.map +0 -1
  161. package/src/endpoint-config.test.ts +0 -54
  162. package/src/fec-client.test.ts +0 -140
  163. package/src/fec-client.ts +0 -1103
  164. package/src/multicast-subscription-pool.test.ts +0 -115
  165. package/src/multicast-tier.test.ts +0 -80
  166. package/src/stall-detector.test.ts +0 -34
  167. package/src/transport-manager.test.ts +0 -192
@@ -4,10 +4,14 @@
4
4
  function endpointKey(endpoint) {
5
5
  return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
6
6
  }
7
+ function carriesMedia(endpoint) {
8
+ return endpoint.carriesMedia !== false;
9
+ }
7
10
  function selectMulticastTier(endpoints, estimate) {
8
- if (endpoints.length <= 1) return endpoints[0];
9
- const ranked = endpoints.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
10
- if (ranked.length < 2) return endpoints[0];
11
+ const media = endpoints.filter(carriesMedia);
12
+ if (media.length <= 1) return media[0];
13
+ const ranked = media.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
14
+ if (ranked.length < 2) return media[0];
11
15
  if (estimate === void 0) return ranked[0];
12
16
  const budget = estimate * 0.8;
13
17
  let selected;
@@ -18,7 +22,7 @@
18
22
  }
19
23
 
20
24
  // src/abr-controller.ts
21
- var QUALITY_LEVELS = [
25
+ var QUALITY_LEVELS = Object.freeze([
22
26
  { name: "240p", bitrate: 3e5, width: 426, height: 240, fps: 15 },
23
27
  { name: "360p", bitrate: 5e5, width: 640, height: 360, fps: 24 },
24
28
  { name: "480p", bitrate: 1e6, width: 854, height: 480, fps: 30 },
@@ -26,8 +30,37 @@
26
30
  { name: "1080p", bitrate: 5e6, width: 1920, height: 1080, fps: 30 },
27
31
  { name: "1440p", bitrate: 8e6, width: 2560, height: 1440, fps: 30 },
28
32
  { name: "4K", bitrate: 15e6, width: 3840, height: 2160, fps: 30 }
29
- ];
33
+ ].map((level) => Object.freeze(level)));
34
+ function positiveFinite(value) {
35
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
36
+ }
37
+ function validateQualityLadder(ladder) {
38
+ if (!Array.isArray(ladder) || ladder.length === 0) {
39
+ throw new TypeError("[ABR] ladder must list at least one rendition (e.g. the catalog's video tracks)");
40
+ }
41
+ const names = /* @__PURE__ */ new Set();
42
+ for (const level of ladder) {
43
+ if (typeof level?.name !== "string" || level.name.length === 0) {
44
+ throw new TypeError("[ABR] every ladder rendition needs a name");
45
+ }
46
+ if (!positiveFinite(level.bitrate)) {
47
+ throw new RangeError(`[ABR] rendition ${level.name}: bitrate must be a positive finite number, got ${String(level.bitrate)}`);
48
+ }
49
+ for (const field of ["width", "height", "fps"]) {
50
+ if (level[field] !== void 0 && !positiveFinite(level[field])) {
51
+ throw new RangeError(`[ABR] rendition ${level.name}: ${field} must be a positive finite number when stated, got ${String(level[field])}`);
52
+ }
53
+ }
54
+ if (names.has(level.name)) throw new RangeError(`[ABR] rendition ${level.name} appears twice in the ladder`);
55
+ names.add(level.name);
56
+ }
57
+ return Object.freeze(
58
+ [...ladder].sort((a, b) => a.bitrate - b.bitrate).map((level) => Object.freeze({ ...level }))
59
+ );
60
+ }
30
61
  var AdaptiveBitrateController = class {
62
+ /** The caller's renditions, ascending by bitrate (`validateQualityLadder`). */
63
+ ladder;
31
64
  currentQualityIndex;
32
65
  history = [];
33
66
  historyWindow = 1e4;
@@ -47,15 +80,21 @@
47
80
  recoveryRate: 1,
48
81
  fecEnabled: false
49
82
  };
50
- constructor(initialQuality) {
51
- this.currentQualityIndex = initialQuality ? QUALITY_LEVELS.findIndex((q) => q.name === initialQuality.name) : 0;
52
- if (this.currentQualityIndex < 0) this.currentQualityIndex = 0;
83
+ constructor(options) {
84
+ if (!options || typeof options !== "object") {
85
+ throw new TypeError("[ABR] AdaptiveBitrateController needs { ladder, initialQuality }");
86
+ }
87
+ this.ladder = validateQualityLadder(options.ladder);
88
+ this.currentQualityIndex = options.initialQuality === void 0 ? 0 : this.ladder.findIndex((q) => q.name === options.initialQuality);
89
+ if (this.currentQualityIndex < 0) {
90
+ throw new RangeError(`[ABR] initial rendition ${String(options.initialQuality)} is not in the ladder`);
91
+ }
53
92
  }
54
93
  setCallback(callback) {
55
94
  this.onQualityChange = callback;
56
95
  }
57
96
  getCurrentQuality() {
58
- return QUALITY_LEVELS[this.currentQualityIndex];
97
+ return this.ladder[this.currentQualityIndex];
59
98
  }
60
99
  /** Selects the multicast (S,G) tier without replacing a player's unicast ABR manager. */
61
100
  selectMulticastTier(endpoints, estimate = this.getMetrics().measuredBitrate || void 0) {
@@ -81,7 +120,7 @@
81
120
  this.downgrade();
82
121
  return;
83
122
  }
84
- if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < QUALITY_LEVELS.length - 1) {
123
+ if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < this.ladder.length - 1) {
85
124
  this.upgrade();
86
125
  }
87
126
  }
@@ -114,7 +153,7 @@
114
153
  return { measuredBitrate, lossRate };
115
154
  }
116
155
  upgrade() {
117
- if (this.currentQualityIndex >= QUALITY_LEVELS.length - 1) return;
156
+ if (this.currentQualityIndex >= this.ladder.length - 1) return;
118
157
  this.currentQualityIndex++;
119
158
  this.lastQualityChange = Date.now();
120
159
  const newQuality = this.getCurrentQuality();
@@ -130,15 +169,15 @@
130
169
  this.onQualityChange?.(newQuality);
131
170
  }
132
171
  /**
133
- * Force quality level (for testing)
172
+ * Select a ladder rendition: the caller switched to it (a manual override,
173
+ * or a switch the caller made). Throws for a name the ladder does not carry.
134
174
  */
135
175
  setQuality(name) {
136
- const index = QUALITY_LEVELS.findIndex((q) => q.name === name);
137
- if (index >= 0) {
138
- this.currentQualityIndex = index;
139
- this.lastQualityChange = Date.now();
140
- this.onQualityChange?.(this.getCurrentQuality());
141
- }
176
+ const index = this.ladder.findIndex((q) => q.name === name);
177
+ if (index < 0) throw new RangeError(`[ABR] rendition ${name} is not in the ladder`);
178
+ this.currentQualityIndex = index;
179
+ this.lastQualityChange = Date.now();
180
+ this.onQualityChange?.(this.getCurrentQuality());
142
181
  }
143
182
  /**
144
183
  * Get current metrics including both raw and effective loss rates
@@ -162,7 +201,91 @@
162
201
  }
163
202
  };
164
203
 
204
+ // node_modules/@blockcast/mmt-alta-parse/dist/index.js
205
+ var MPU_HEADER_SIZE = 8;
206
+ var MPU_LENGTH_FIELD_SIZE = 2;
207
+ var MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE;
208
+
209
+ // node_modules/@blockcast/mmt-base/dist/mmtp.js
210
+ var MPU_HEADER_SIZE2 = 8;
211
+ var MPU_LENGTH_FIELD_SIZE2 = 2;
212
+ var MPU_HEADER_REMAINDER_SIZE2 = MPU_HEADER_SIZE2 - MPU_LENGTH_FIELD_SIZE2;
213
+ var MMTP_SIGNALING_PACKET_ID = 0;
214
+ var MMTP_PACKET_ID_MAX = 65535;
215
+
216
+ // node_modules/@blockcast/mmt-base/dist/net-constants.js
217
+ var DEFAULT_AMT_RELAY_PORT = 2268;
218
+
219
+ // node_modules/@blockcast/mmt-base/dist/ntp-short.js
220
+ var NTP_SHORT_BITS = 32;
221
+ var NTP_SHORT_MODULUS = 2 ** NTP_SHORT_BITS;
222
+ var NTP_SHORT_HALF_RANGE = 2 ** (NTP_SHORT_BITS - 1);
223
+ function isNtpShort(value) {
224
+ return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS;
225
+ }
226
+ function compareNtpShort(a, b) {
227
+ if (!isNtpShort(a) || !isNtpShort(b)) {
228
+ throw new RangeError(`NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS}), got ${String(a)} and ${String(b)}`);
229
+ }
230
+ if (a === b)
231
+ return "equal";
232
+ const forward = (a - b + NTP_SHORT_MODULUS) % NTP_SHORT_MODULUS;
233
+ if (forward < NTP_SHORT_HALF_RANGE)
234
+ return "after";
235
+ if (forward > NTP_SHORT_HALF_RANGE)
236
+ return "before";
237
+ return "unordered";
238
+ }
239
+
240
+ // node_modules/@blockcast/mmt-base/dist/fec-block-profile.js
241
+ var FEC_BLOCK_PROFILES = Object.freeze([
242
+ "k-padded-v1",
243
+ "iso-ssbg1-v1"
244
+ ]);
245
+
165
246
  // src/endpoint-config.ts
247
+ function toWindowMulticastSubscribeConfig(input) {
248
+ if (input.mode !== void 0 && !["auto", "native", "tunnel"].includes(input.mode)) {
249
+ throw new TypeError(`Unsupported multicast mode: ${String(input.mode)}`);
250
+ }
251
+ if (input.packetIds !== void 0 && !Array.isArray(input.packetIds)) {
252
+ throw new TypeError(
253
+ `multicast packetIds must be an array, got ${input.packetIds === null ? "null" : typeof input.packetIds}`
254
+ );
255
+ }
256
+ if (input.packetIds?.some((id) => !isMulticastTrackPacketId(id)) || input.tracks?.some((track) => !isMulticastTrackPacketId(track.packetId))) {
257
+ throw new RangeError(
258
+ `multicast packetId must be an integer from 1 to ${MMTP_PACKET_ID_MAX}; packetId 0 is the MMTP signaling flow and is never a track's`
259
+ );
260
+ }
261
+ const base = {
262
+ group: input.group,
263
+ port: input.port,
264
+ ...input.source?.trim() ? { source: input.source.trim() } : {},
265
+ consume: "bytes",
266
+ ...input.packetIds?.length ? { packetIds: [...input.packetIds] } : {}
267
+ };
268
+ const relay = input.amtRelay?.trim();
269
+ const wantsAmt = input.mode === "tunnel" || input.mode !== "native" && relay !== void 0 && relay !== "";
270
+ if (!wantsAmt) {
271
+ const native = {
272
+ ...base,
273
+ origin: "ip-multicast",
274
+ ...input.tracks?.length ? { tracks: input.tracks.map(({ name, packetId }) => ({ name, packetId })) } : {}
275
+ };
276
+ return native;
277
+ }
278
+ const relayPort = input.amtRelayPort;
279
+ if (!relay || relayPort === void 0 || !isPositivePort(relayPort)) {
280
+ throw new TypeError("AMT requires a resolved relay and a valid relay port");
281
+ }
282
+ return {
283
+ ...base,
284
+ origin: "amt",
285
+ relay,
286
+ relayPort
287
+ };
288
+ }
166
289
  function isValidMulticastGroupAddress(address) {
167
290
  const value = address?.trim().toLowerCase();
168
291
  if (!value) return false;
@@ -204,20 +327,36 @@
204
327
  normalized.amtRelayPort ?? ""
205
328
  ]);
206
329
  }
330
+ function isMulticastTrackPacketId(value) {
331
+ return Number.isInteger(value) && value > MMTP_SIGNALING_PACKET_ID && value <= MMTP_PACKET_ID_MAX;
332
+ }
207
333
  function isUsableMulticastEndpoint(endpoint) {
208
334
  const port = Number(endpoint?.port);
209
335
  return isValidMulticastGroupAddress(endpoint?.groupAddress) && isPositivePort(port);
210
336
  }
337
+ var RETIRED_MULTICAST_URL_PARAMS = [
338
+ ["amt_relay", "mc_amt_relay"],
339
+ ["amt_relay_port", "mc_amt_relay_port"]
340
+ ];
341
+ function assertNoRetiredMulticastUrlParams(params) {
342
+ for (const [retired, replacement] of RETIRED_MULTICAST_URL_PARAMS) {
343
+ if (params.has(retired)) {
344
+ throw new Error(
345
+ `multicast URL override: query parameter "${retired}" is retired; use "${replacement}"`
346
+ );
347
+ }
348
+ }
349
+ }
211
350
  function readUrlMulticastConfig(search = typeof location === "undefined" ? "" : location.search) {
212
351
  const params = typeof search === "string" ? new URLSearchParams(search) : search;
213
352
  const groupAddress = params.get("mc_group") || "";
214
353
  const port = positivePort(params.get("mc_port"));
215
354
  if (!groupAddress || !port) return void 0;
216
- const amtRelay = params.get("mc_amt_relay") || params.get("amt_relay") || void 0;
217
- const amtRelayPort = positivePort(params.get("mc_amt_relay_port") || params.get("amt_relay_port"));
355
+ const amtRelay = params.get("mc_amt_relay") || void 0;
356
+ const amtRelayPort = positivePort(params.get("mc_amt_relay_port"));
218
357
  const requestedMode = params.get("mc_mode");
219
358
  const mode = amtRelay ? "tunnel" : multicastMode(requestedMode);
220
- return normalizeMulticastEndpoint({
359
+ const endpoint = normalizeMulticastEndpoint({
221
360
  groupAddress,
222
361
  sourceAddress: params.get("mc_source") || void 0,
223
362
  port,
@@ -225,6 +364,8 @@
225
364
  ...amtRelay ? { amtRelay } : {},
226
365
  ...amtRelayPort ? { amtRelayPort } : {}
227
366
  });
367
+ if (endpoint) assertNoRetiredMulticastUrlParams(params);
368
+ return endpoint;
228
369
  }
229
370
  function isMulticastExplicitlyDisabled(search = typeof location === "undefined" ? "" : location.search) {
230
371
  const params = typeof search === "string" ? new URLSearchParams(search) : search;
@@ -270,7 +411,12 @@
270
411
  #estimate;
271
412
  #favored;
272
413
  #joinStartedAt = 0;
273
- #lastCommittedTimestamp = Number.NEGATIVE_INFINITY;
414
+ /**
415
+ * Latest active-tier send time (NTP short), or undefined before the first.
416
+ * "Latest" is serial order, not the numerically largest value: after the
417
+ * field wraps, the largest value is the stale one.
418
+ */
419
+ #highWater;
274
420
  #dwellMs;
275
421
  #joinDeadlineMs;
276
422
  get state() {
@@ -291,19 +437,57 @@
291
437
  }
292
438
  onGoodput(sample) {
293
439
  const bitrate = sample.bitrate;
294
- this.#estimate = bitrate;
295
440
  if (!this.#current || this.options.adaptive === false) return void 0;
296
441
  if (this.#state === "joining") return this.poll();
442
+ if (bitrate === void 0 || !Number.isFinite(bitrate)) return void 0;
443
+ this.#estimate = bitrate;
297
444
  const desired = selectMulticastTier(this.options.endpoints, bitrate);
298
445
  if (!desired) return void 0;
299
446
  const desiredKey = endpointKey(desired);
447
+ if (desiredKey === endpointKey(this.#current)) {
448
+ if (this.#favored?.source === "goodput") this.#favored = void 0;
449
+ return void 0;
450
+ }
451
+ if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" || desired.bandwidth >= this.#current.bandwidth) {
452
+ if (this.#favored?.source === "goodput") this.#favored = void 0;
453
+ return void 0;
454
+ }
455
+ const now = this.#now();
456
+ if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
457
+ this.#favored = { key: desiredKey, since: now, source: "goodput" };
458
+ return void 0;
459
+ }
460
+ if (now - this.#favored.since < this.#dwellMs) return void 0;
461
+ this.#state = "joining";
462
+ this.#target = desired;
463
+ this.#joinStartedAt = now;
464
+ this.#favored = void 0;
465
+ return { kind: "join-target", current: this.#current, target: desired };
466
+ }
467
+ onHeadroom(sample) {
468
+ if (!this.#current || this.options.adaptive === false || this.#state === "joining") return void 0;
469
+ if (!sample.healthy) {
470
+ if (this.#favored?.source === "headroom") this.#favored = void 0;
471
+ return void 0;
472
+ }
473
+ if (this.#favored?.source === "goodput") return void 0;
474
+ const currentBandwidth = this.#current.bandwidth;
475
+ if (typeof currentBandwidth !== "number") return void 0;
476
+ const desired = this.options.endpoints.filter(
477
+ (endpoint) => carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth
478
+ ).sort((a, b) => a.bandwidth - b.bandwidth)[0];
479
+ if (!desired) {
480
+ this.#favored = void 0;
481
+ return void 0;
482
+ }
483
+ const desiredKey = endpointKey(desired);
300
484
  if (desiredKey === endpointKey(this.#current)) {
301
485
  this.#favored = void 0;
302
486
  return void 0;
303
487
  }
304
488
  const now = this.#now();
305
489
  if (!this.#favored || this.#favored.key !== desiredKey) {
306
- this.#favored = { key: desiredKey, since: now };
490
+ this.#favored = { key: desiredKey, since: now, source: "headroom" };
307
491
  return void 0;
308
492
  }
309
493
  if (now - this.#favored.since < this.#dwellMs) return void 0;
@@ -313,21 +497,32 @@
313
497
  this.#favored = void 0;
314
498
  return { kind: "join-target", current: this.#current, target: desired };
315
499
  }
500
+ /**
501
+ * Feed a packet from the active tier (to advance the high-water mark) or
502
+ * from the in-flight target (to find a committable RAP).
503
+ *
504
+ * Serial order is only the true order while the compared instants are less
505
+ * than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
506
+ * active-tier packet keeps the mark that close to the live stream. A mark
507
+ * refreshed only by commits can fall further behind than that, and a
508
+ * target RAP may then compare as older, so the join is abandoned.
509
+ */
316
510
  onTierPacket(packet) {
317
511
  if (!this.#current) return void 0;
318
- if (packet.endpointKey === endpointKey(this.#current) && Number.isFinite(packet.timestamp)) {
319
- this.#lastCommittedTimestamp = Math.max(this.#lastCommittedTimestamp, packet.timestamp);
512
+ const sentAt = tierPacketSendTime(packet);
513
+ if (packet.endpointKey === endpointKey(this.#current) && sentAt !== void 0 && (this.#highWater === void 0 || compareNtpShort(sentAt, this.#highWater) === "after")) {
514
+ this.#highWater = sentAt;
320
515
  }
321
516
  if (this.#state !== "joining" || !this.#target) return void 0;
322
517
  const expired = this.poll();
323
518
  if (expired) return expired;
324
- if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || !Number.isFinite(packet.timestamp) || packet.timestamp < this.#lastCommittedTimestamp) return void 0;
519
+ if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || sentAt === void 0 || !this.#reachesHighWater(sentAt)) return void 0;
325
520
  const command = {
326
521
  kind: "commit-swap",
327
522
  target: this.#target,
328
523
  old: this.#current
329
524
  };
330
- this.#lastCommittedTimestamp = packet.timestamp;
525
+ this.#highWater = sentAt;
331
526
  this.#current = this.#target;
332
527
  this.#target = void 0;
333
528
  this.#state = "stable";
@@ -349,12 +544,481 @@
349
544
  this.#state = "stable";
350
545
  return command;
351
546
  }
547
+ /**
548
+ * Whether a target RAP sent at `sentAt` is at or beyond the active tier's
549
+ * high-water mark. A pair exactly half the NTP-short space apart has no
550
+ * serial order, so it cannot show that and does not commit.
551
+ */
552
+ #reachesHighWater(sentAt) {
553
+ if (this.#highWater === void 0) return true;
554
+ const order = compareNtpShort(sentAt, this.#highWater);
555
+ return order === "after" || order === "equal";
556
+ }
352
557
  #now() {
353
558
  const now = this.options.now();
354
559
  if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
355
560
  return now;
356
561
  }
357
562
  };
563
+ function tierPacketSendTime(packet) {
564
+ if (!Number.isFinite(packet.timestamp)) return void 0;
565
+ if (!isNtpShort(packet.timestamp)) {
566
+ throw new RangeError(
567
+ `Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`
568
+ );
569
+ }
570
+ return packet.timestamp;
571
+ }
572
+
573
+ // src/multicast-signaling-reconciler.ts
574
+ function createMulticastSignalingReconciler(catalog, options = {}) {
575
+ const initial = normalizeCatalogEndpoints(catalog);
576
+ const endpoints = new Map(initial.map((endpoint) => [endpoint.endpointId, copyEndpoint(endpoint)]));
577
+ const versions = new Map(initial.map((endpoint) => [endpoint.endpointId, endpoint.endpointVersion]));
578
+ const seen = /* @__PURE__ */ new Set();
579
+ const dormant = /* @__PURE__ */ new Set();
580
+ let catalogVersion = Number(catalog?.multicast?.version ?? catalog?.version ?? 0) || 0;
581
+ const snapshot = (now = Date.now()) => ({
582
+ catalogVersion,
583
+ endpoints: [...endpoints.values()].filter((endpoint) => !dormant.has(endpoint.endpointId) && !isExpired(endpoint, now)).map(copyEndpoint),
584
+ dormantEndpointIds: [...dormant].sort()
585
+ });
586
+ return {
587
+ apply(signal, now = Date.now()) {
588
+ if (!isObject(signal) || typeof signal.type !== "string" || typeof signal.msgId !== "string") return { applied: false, reason: "invalid-message" };
589
+ if (seen.has(signal.msgId)) return { applied: false, reason: "duplicate-message" };
590
+ if (signal.expiresAt != null && Number(signal.expiresAt) < now) return { applied: false, reason: "expired" };
591
+ const signalCatalogVersion = Number(signal.catalogVersion ?? 0);
592
+ if (!Number.isFinite(signalCatalogVersion)) return { applied: false, reason: "invalid-message" };
593
+ if (signalCatalogVersion < catalogVersion) return { applied: false, reason: "stale-catalog" };
594
+ const result = applyFresh(signal, now);
595
+ if (result.applied) {
596
+ seen.add(signal.msgId);
597
+ if (signal.type !== "heartbeat") {
598
+ catalogVersion = Math.max(catalogVersion, signalCatalogVersion);
599
+ }
600
+ }
601
+ return result;
602
+ },
603
+ snapshot,
604
+ toCatalog(now = Date.now()) {
605
+ const current = snapshot(now);
606
+ return { ...catalog, multicast: { ...catalog.multicast, version: current.catalogVersion, endpoints: current.endpoints } };
607
+ }
608
+ };
609
+ function applyFresh(signal, now) {
610
+ switch (signal.type) {
611
+ case "snapshot": {
612
+ const next = signal.payload?.endpoints;
613
+ if (!Array.isArray(next)) return { applied: false, reason: "invalid-message" };
614
+ const normalized = next.map((endpoint, index) => normalizeEndpoint(endpoint, index));
615
+ if (normalized.some((endpoint) => !endpoint || !isValidEndpoint(endpoint, options.catalog))) return { applied: false, reason: "invalid-endpoint" };
616
+ const ids = new Set(normalized.map((endpoint) => endpoint.endpointId));
617
+ if (ids.size !== normalized.length) return { applied: false, reason: "duplicate-endpoint" };
618
+ endpoints.clear();
619
+ versions.clear();
620
+ dormant.clear();
621
+ for (const endpoint of normalized) {
622
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
623
+ if (!isExpired(endpoint, now)) endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
624
+ }
625
+ return { applied: true, action: "snapshot" };
626
+ }
627
+ case "add":
628
+ return upsert(signal.payload?.endpoint, now);
629
+ case "update": {
630
+ const id = signal.payload?.endpointId;
631
+ const fields = signal.payload?.fields;
632
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
633
+ if (isStale(id, signal.payload?.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
634
+ if (!isObject(fields) || ["protocol", "sourceAddress", "groupAddress", "port"].some((key) => Object.hasOwn(fields, key))) return { applied: false, reason: "invalid-update" };
635
+ return upsert({ ...endpoints.get(id), ...fields, endpointVersion: signal.payload.endpointVersion }, now);
636
+ }
637
+ case "remove": {
638
+ const id = signal.payload?.endpointId;
639
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
640
+ const endpointVersion = Number(signal.payload?.endpointVersion);
641
+ if (!Number.isFinite(endpointVersion)) return { applied: false, reason: "invalid-message" };
642
+ if (isStale(id, endpointVersion)) return { applied: false, reason: "stale-endpoint" };
643
+ endpoints.delete(id);
644
+ dormant.delete(id);
645
+ versions.set(id, endpointVersion);
646
+ return { applied: true, action: "drop" };
647
+ }
648
+ case "invalidate": {
649
+ const id = signal.payload?.endpointId;
650
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
651
+ dormant.add(id);
652
+ return { applied: true, action: "drop" };
653
+ }
654
+ case "heartbeat":
655
+ return { applied: true, action: "heartbeat" };
656
+ default:
657
+ return { applied: false, reason: "unknown-message-type" };
658
+ }
659
+ }
660
+ function upsert(value, now) {
661
+ const endpoint = normalizeEndpoint(value, endpoints.size);
662
+ if (!endpoint || !isValidEndpoint(endpoint, options.catalog)) return { applied: false, reason: "invalid-endpoint" };
663
+ if (isStale(endpoint.endpointId, endpoint.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
664
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
665
+ dormant.delete(endpoint.endpointId);
666
+ if (isExpired(endpoint, now)) endpoints.delete(endpoint.endpointId);
667
+ else endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
668
+ return { applied: true, action: isExpired(endpoint, now) ? "drop" : "candidate" };
669
+ }
670
+ function isStale(id, version) {
671
+ const current = versions.get(id);
672
+ return current !== void 0 && Number(version) <= current;
673
+ }
674
+ }
675
+ function normalizeCatalogEndpoints(catalog) {
676
+ const endpoints = catalog?.multicast?.endpoints;
677
+ if (!Array.isArray(endpoints)) return [];
678
+ return endpoints.map(normalizeEndpoint).filter((endpoint) => !!endpoint && isValidEndpoint(endpoint, catalog));
679
+ }
680
+ function normalizeEndpoint(value, index) {
681
+ if (!isObject(value)) return void 0;
682
+ const endpoint = value;
683
+ const tracks = Array.isArray(endpoint.tracks) ? endpoint.tracks.map((track) => typeof track === "string" ? { name: track } : { ...track }).filter((track) => track.name) : [];
684
+ return { ...endpoint, endpointId: endpoint.endpointId || `${endpoint.sourceAddress || ""}|${endpoint.groupAddress || ""}|${endpoint.port || ""}|${index}`, endpointVersion: Number(endpoint.endpointVersion ?? 0), tracks };
685
+ }
686
+ function isValidEndpoint(endpoint, catalog) {
687
+ if (!endpoint.endpointId || !Number.isFinite(endpoint.endpointVersion)) return false;
688
+ if (!["ssm", "asm", "amt"].includes(endpoint.protocol)) return false;
689
+ if (endpoint.protocol === "ssm" && (typeof endpoint.sourceAddress !== "string" || endpoint.sourceAddress.trim().length === 0)) return false;
690
+ if (!isValidMulticastGroupAddress(endpoint.groupAddress) || !Number.isInteger(Number(endpoint.port)) || endpoint.port <= 0 || endpoint.port > 65535 || !endpoint.tracks.length) return false;
691
+ const catalogTracks = /* @__PURE__ */ new Set([...Object.keys(catalog?.video?.renditions ?? {}), ...Object.keys(catalog?.audio?.renditions ?? {})]);
692
+ return endpoint.tracks.every((track) => !!track.name && isMulticastTrackPacketId(track.packetId) && (catalogTracks.size === 0 || catalogTracks.has(track.name) || catalogTracks.has(track.name.replace(/\/repair$/, ""))));
693
+ }
694
+ function copyEndpoint(endpoint) {
695
+ return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
696
+ }
697
+ function isExpired(endpoint, now) {
698
+ return endpoint.validUntil != null && Number(endpoint.validUntil) < now;
699
+ }
700
+ function isObject(value) {
701
+ return value !== null && typeof value === "object";
702
+ }
703
+
704
+ // src/multicast-catalog-policy.ts
705
+ function hasMulticastCatalogBaseline(catalog) {
706
+ const multicast = catalog?.multicast;
707
+ return normalizeCatalogEndpoints(catalog).length > 0 || Boolean(multicast?.groupAddress && multicast?.port);
708
+ }
709
+ function hasUnicastMediaRenditions(catalog) {
710
+ return Object.keys(catalog?.video?.renditions ?? {}).length > 0 || Object.keys(catalog?.audio?.renditions ?? {}).length > 0;
711
+ }
712
+ function isMulticastOnlyCatalog(catalog) {
713
+ return hasMulticastCatalogBaseline(catalog) && !hasUnicastMediaRenditions(catalog);
714
+ }
715
+ function requiresMulticastCapability(catalog, capabilities = {}) {
716
+ return isMulticastOnlyCatalog(catalog) && !capabilities.multicast;
717
+ }
718
+ function isRecoverableMulticastSignalReadError(error) {
719
+ const candidate = error;
720
+ const name = String(candidate?.name ?? "");
721
+ const message = String(candidate?.message ?? error ?? "");
722
+ return name === "SyntaxError" || /not valid JSON|unexpected token|JSON\.parse|encoded data was not valid|malformed/i.test(message);
723
+ }
724
+ function withMulticastEndpoints(catalog, endpoints, version) {
725
+ if (!catalog?.multicast) return catalog;
726
+ return {
727
+ ...catalog,
728
+ multicast: {
729
+ ...catalog.multicast,
730
+ version,
731
+ endpoints: endpoints.map(copyEndpoint2)
732
+ }
733
+ };
734
+ }
735
+ function selectMulticastCandidate(catalog, endpoints = normalizeCatalogEndpoints(catalog), search = currentSearchParams()) {
736
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
737
+ return selectMulticastCandidateForTracks(catalog, endpoints, {
738
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
739
+ rendition: params.get("rendition") || void 0
740
+ });
741
+ }
742
+ function selectMulticastCandidateForTracks(catalog, endpoints = normalizeCatalogEndpoints(catalog), selection = {}) {
743
+ const packaging = selection.containerKind || "mmtp";
744
+ const renditionTrack = selection.rendition || "";
745
+ const preferredTrack = packaging === "mmtp" ? "video" : `video.${packaging}`;
746
+ const usableEndpoints = endpoints.filter(isUsableMulticastEndpoint);
747
+ const defaultRenditionTrack = renditionTrack ? "" : lowestServedVideoRendition(catalog, usableEndpoints);
748
+ return renditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, renditionTrack))) || defaultRenditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, defaultRenditionTrack))) || usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, preferredTrack))) || usableEndpoints[0] || null;
749
+ }
750
+ function endpointToMulticastConfig(endpoint, catalog, selection) {
751
+ if (!endpoint || !isUsableMulticastEndpoint(endpoint) || advertisesInvalidPacketId(endpoint)) return null;
752
+ const config = {
753
+ group: endpoint.groupAddress,
754
+ source: endpoint.sourceAddress || "",
755
+ port: Number(endpoint.port)
756
+ };
757
+ const networkSource = endpoint.networkSource ?? catalog?.multicast?.networkSource;
758
+ const amt = findAmtRelay(networkSource);
759
+ if (amt) {
760
+ config.mode = "tunnel";
761
+ config.amtRelay = amt.relay;
762
+ config.amtRelayPort = amt.port;
763
+ } else if (endpoint.protocol === "amt") {
764
+ const amtEntries = amtNetworkSourceEntries(networkSource);
765
+ if (amtEntries.length > 0) config.amtNetworkSource = amtEntries;
766
+ }
767
+ const packetIds = selection === void 0 ? selectEndpointPacketIds(endpoint, catalog) : selectEndpointPacketIdsForTracks(endpoint, catalog, selection);
768
+ if (packetIds === null) return null;
769
+ if (packetIds.length > 0) config.packetIds = packetIds;
770
+ return config;
771
+ }
772
+ function selectEndpointPacketIds(endpoint, catalog, search = currentSearchParams()) {
773
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
774
+ return selectEndpointPacketIdsForTracks(endpoint, catalog, {
775
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
776
+ rendition: params.get("rendition") || void 0
777
+ });
778
+ }
779
+ function selectEndpointPacketIdsForTracks(endpoint, catalog, selection = {}) {
780
+ if (!endpoint || !Array.isArray(endpoint.tracks)) return [];
781
+ if (advertisesInvalidPacketId(endpoint)) return null;
782
+ const tracks = endpoint.tracks.map((track) => ({
783
+ name: typeof track?.name === "string" ? track.name : "",
784
+ packetId: validPacketId(track?.packetId)
785
+ })).filter((track) => track.name.length > 0 && track.packetId !== void 0);
786
+ if (tracks.length === 0) return [];
787
+ const packaging = selection.containerKind || "mmtp";
788
+ const renditionTrack = selection.rendition || "";
789
+ const videoRenditions = catalogRenditions(catalog, "video");
790
+ const audioRenditions = catalogRenditions(catalog, "audio");
791
+ const videoTracks = tracks.filter((track) => Object.hasOwn(videoRenditions, track.name));
792
+ const audioTracks = tracks.filter((track) => Object.hasOwn(audioRenditions, track.name));
793
+ const preferredVideo = packaging === "mmtp" ? "video" : `video.${packaging}`;
794
+ const preferredAudio = packaging === "mmtp" ? "audio" : `audio.${packaging}`;
795
+ const video = (renditionTrack ? videoTracks.find((track) => track.name === renditionTrack) : void 0) ?? videoTracks.find((track) => track.name === preferredVideo) ?? [...videoTracks].sort((a, b) => videoRenditionRank(videoRenditions, a.name) - videoRenditionRank(videoRenditions, b.name))[0];
796
+ const audio = audioTracks.find((track) => track.name === preferredAudio) ?? audioTracks[0];
797
+ const selected = [video, audio].filter(
798
+ (track) => track !== void 0
799
+ );
800
+ if (selected.length === 0) return normalizePacketIds(tracks.map((track) => track.packetId));
801
+ const selectedRepairNames = new Set(selected.flatMap((track) => catalogRepairTracks(catalog, track.name)));
802
+ const selectedRepairTracks = tracks.filter((track) => selectedRepairNames.has(track.name));
803
+ return normalizePacketIds([...selected, ...selectedRepairTracks].map((track) => track.packetId));
804
+ }
805
+ function readPlayerMulticastConfig(search = currentSearchParams()) {
806
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search || "");
807
+ const group = params.get("mc_group") || "";
808
+ if (!isValidMulticastGroupAddress(group)) return null;
809
+ const port = positivePort2(params.get("mc_port"));
810
+ if (!port) return null;
811
+ assertNoRetiredMulticastUrlParams(params);
812
+ const mode = params.get("mc_mode") || "";
813
+ const amtRelay = params.get("mc_amt_relay") || "";
814
+ const amtRelayPort = positivePort2(params.get("mc_amt_relay_port"));
815
+ const config = {
816
+ group,
817
+ source: params.get("mc_source") || "",
818
+ port
819
+ };
820
+ if (mode) config.mode = mode;
821
+ if (amtRelay) {
822
+ config.amtRelay = amtRelay;
823
+ if (amtRelay.trim()) config.mode = "tunnel";
824
+ }
825
+ if (amtRelayPort) config.amtRelayPort = amtRelayPort;
826
+ return config;
827
+ }
828
+ function normalizeMulticastConfig(config) {
829
+ if (!config?.group) return null;
830
+ if (Array.isArray(config.packetIds) && !config.packetIds.every(isMulticastTrackPacketId)) return null;
831
+ const amt = findAmtRelay(config.amtNetworkSource);
832
+ const amtRelay = config.amtRelay || amt?.relay || "";
833
+ const dialableRelay = amtRelay.trim();
834
+ const amtRelayPort = positivePort2(String(config.amtRelayPort ?? "")) ?? (dialableRelay ? amt?.port ?? DEFAULT_AMT_RELAY_PORT : void 0);
835
+ const packetIds = normalizePacketIds(config.packetIds);
836
+ const pendingAmt = dialableRelay ? [] : amtNetworkSourceEntries(config.amtNetworkSource);
837
+ return {
838
+ group: config.group,
839
+ source: config.source || "",
840
+ port: Number(config.port),
841
+ mode: dialableRelay ? "tunnel" : config.mode || "auto",
842
+ amtRelay,
843
+ ...amtRelayPort ? { amtRelayPort } : {},
844
+ // Relay-pending DRIAD survives normalization while it is still pending:
845
+ // dropping it here would erase, one hop before the join, the only thing
846
+ // telling the call site "AMT was asked for and is unresolved" apart from
847
+ // "this endpoint was always native" (BLO-38184). Once a relay resolves
848
+ // the entries have done their job and are not carried. `amtNetworkSource`
849
+ // is deliberately not part of `sameMulticastConfig`: it is provenance for
850
+ // the relay, not endpoint identity, so it must not report a change.
851
+ //
852
+ // That holds only while no caller acts on the field — today a pending
853
+ // DRIAD entry and a native endpoint both join `ip-multicast`, so
854
+ // comparing equal reports the truth. Once a caller resolves DRIAD and
855
+ // tunnels, a catalog swapping native for DRIAD-AMT becomes a real
856
+ // join-behaviour change that would compare equal and never re-join:
857
+ // whoever wires that resolution adds `amtNetworkSource` to
858
+ // `sameMulticastConfig` in the same change.
859
+ ...pendingAmt.length > 0 ? { amtNetworkSource: pendingAmt } : {},
860
+ ...packetIds.length > 0 ? { packetIds } : {}
861
+ };
862
+ }
863
+ function sameMulticastConfig(a, b) {
864
+ const left = normalizeMulticastConfig(a);
865
+ const right = normalizeMulticastConfig(b);
866
+ return Boolean(left && right && left.group === right.group && left.source === right.source && left.port === right.port && left.mode === right.mode && left.amtRelay === right.amtRelay && (left.amtRelayPort ?? 0) === (right.amtRelayPort ?? 0) && JSON.stringify(left.packetIds ?? []) === JSON.stringify(right.packetIds ?? []));
867
+ }
868
+ function urlOverrideBlocksMulticastCandidate(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
869
+ if (!urlConfig) return false;
870
+ const candidate = normalizeMulticastConfig(candidateConfig);
871
+ const explicit = normalizeMulticastConfig(urlConfig);
872
+ return !candidate || !explicit || candidate.group !== explicit.group || candidate.source !== explicit.source || candidate.port !== explicit.port;
873
+ }
874
+ function mergeUrlMulticastPolicy(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
875
+ const candidate = normalizeMulticastConfig(candidateConfig);
876
+ if (!candidate || !urlConfig || urlOverrideBlocksMulticastCandidate(candidate, urlConfig)) return candidate;
877
+ const merged = { ...candidate };
878
+ if (urlConfig.mode !== void 0) {
879
+ merged.mode = urlConfig.mode;
880
+ if (urlConfig.mode !== "tunnel" && urlConfig.amtRelay === void 0) {
881
+ merged.amtRelay = "";
882
+ delete merged.amtRelayPort;
883
+ delete merged.amtNetworkSource;
884
+ }
885
+ }
886
+ if (urlConfig.amtRelay !== void 0) {
887
+ merged.amtRelay = urlConfig.amtRelay;
888
+ delete merged.amtRelayPort;
889
+ if (urlConfig.amtRelay.trim()) {
890
+ merged.mode = "tunnel";
891
+ delete merged.amtNetworkSource;
892
+ } else if (urlConfig.mode === void 0 && candidate.amtRelay?.trim()) {
893
+ merged.mode = "auto";
894
+ }
895
+ }
896
+ if (urlConfig.amtRelayPort !== void 0) merged.amtRelayPort = urlConfig.amtRelayPort;
897
+ return normalizeMulticastConfig(merged);
898
+ }
899
+ function currentSearchParams() {
900
+ return typeof location === "undefined" ? new URLSearchParams() : new URLSearchParams(location.search);
901
+ }
902
+ function positivePort2(value) {
903
+ const port = Number(value);
904
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
905
+ }
906
+ function copyEndpoint2(endpoint) {
907
+ return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
908
+ }
909
+ function amtNetworkSourceEntries(networkSource) {
910
+ const sources = Array.isArray(networkSource) ? networkSource : networkSource ? [networkSource] : [];
911
+ return sources.filter((entry) => entry?.type === "amt").map((entry) => ({ ...entry }));
912
+ }
913
+ function findAmtRelay(networkSource) {
914
+ const source = amtNetworkSourceEntries(networkSource).find((candidate) => candidate?.discovery !== "driad" && typeof candidate?.relay === "string" && candidate.relay.trim());
915
+ if (!source) return void 0;
916
+ const port = positivePort2(String(source.port ?? ""));
917
+ return { relay: source.relay.trim(), port: port ?? DEFAULT_AMT_RELAY_PORT };
918
+ }
919
+ function validPacketId(value) {
920
+ return isMulticastTrackPacketId(value) ? value : void 0;
921
+ }
922
+ function advertisesInvalidPacketId(endpoint) {
923
+ return Array.isArray(endpoint.tracks) && endpoint.tracks.some((track) => track?.packetId !== void 0 && !isMulticastTrackPacketId(track.packetId));
924
+ }
925
+ function normalizePacketIds(packetIds) {
926
+ if (!Array.isArray(packetIds)) return [];
927
+ return [...new Set(packetIds.map(validPacketId).filter((id) => id !== void 0))].sort(
928
+ (a, b) => a - b
929
+ );
930
+ }
931
+ function matchesPreferredTrack(trackName, preferredTrack) {
932
+ return trackName === preferredTrack || trackName.startsWith(`${preferredTrack}/`);
933
+ }
934
+ function catalogRenditions(catalog, role) {
935
+ const renditions = { ...catalog?.[role]?.renditions ?? {} };
936
+ for (const track of Array.isArray(catalog?.tracks) ? catalog.tracks : []) {
937
+ if (track?.role === role && typeof track.name === "string" && track.name.length > 0) {
938
+ renditions[track.name] = track;
939
+ }
940
+ }
941
+ return renditions;
942
+ }
943
+ function assertNoRetiredTrackFields(catalog) {
944
+ if (catalog && typeof catalog === "object" && "commonTrackFields" in catalog) {
945
+ throw new Error("multicast catalog policy: $.commonTrackFields is retired; every track must declare its own packaging");
946
+ }
947
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
948
+ tracks.forEach((track, index) => {
949
+ if (track && typeof track === "object" && "selectionParams" in track) {
950
+ throw new Error(`multicast catalog policy: $.tracks[${index}].selectionParams is retired; selection fields are flat on the track`);
951
+ }
952
+ });
953
+ }
954
+ function catalogRepairTracks(catalog, name) {
955
+ assertNoRetiredTrackFields(catalog);
956
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
957
+ const track = tracks.find((candidate) => candidate?.name === name);
958
+ const rendition = catalog?.video?.renditions?.[name] ?? catalog?.audio?.renditions?.[name];
959
+ const fec = track?.fec ?? rendition?.fec;
960
+ if (!fec || fec.algorithm === "none") return [];
961
+ const names = /* @__PURE__ */ new Set();
962
+ if (typeof fec.repairTrack === "string" && fec.repairTrack.length > 0) names.add(fec.repairTrack);
963
+ const dependsOn = (entry) => Array.isArray(entry?.depends) && entry.depends.length === 1 && entry.depends[0] === name;
964
+ for (const candidate of tracks) {
965
+ const packaging = candidate?.packaging;
966
+ if (packaging === "fec-repair" && typeof candidate?.name === "string" && candidate.name.length > 0 && dependsOn(candidate)) {
967
+ names.add(candidate.name);
968
+ }
969
+ }
970
+ const repairRenditions = catalog?.repair?.renditions;
971
+ if (repairRenditions && typeof repairRenditions === "object") {
972
+ for (const [repairName, entry] of Object.entries(repairRenditions)) {
973
+ if (repairName.length > 0 && dependsOn(entry)) names.add(repairName);
974
+ }
975
+ }
976
+ return [...names];
977
+ }
978
+ function catalogQualityLadder(catalog, role = "video") {
979
+ return Object.entries(catalogRenditions(catalog, role)).map(([name, config]) => {
980
+ const { bitrate, codedWidth, codedHeight, framerate } = config ?? {};
981
+ if (!isPositiveFinite(bitrate)) {
982
+ throw new RangeError(`[ABR] catalog ${role} rendition ${name} states no positive bitrate to cost it by`);
983
+ }
984
+ const rung = { name, bitrate };
985
+ if (isPositiveFinite(codedWidth)) rung.width = codedWidth;
986
+ if (isPositiveFinite(codedHeight)) rung.height = codedHeight;
987
+ if (isPositiveFinite(framerate)) rung.fps = framerate;
988
+ return rung;
989
+ });
990
+ }
991
+ function isPositiveFinite(value) {
992
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
993
+ }
994
+ function videoRenditionRank(config, name) {
995
+ if (typeof config?.bitrate === "number" && config.bitrate > 0) return config.bitrate;
996
+ const width = typeof config?.codedWidth === "number" ? config.codedWidth : 0;
997
+ const height = typeof config?.codedHeight === "number" ? config.codedHeight : 0;
998
+ if (width > 0 && height > 0) return width * height;
999
+ const match = /(?:^|[/_.-])(\d{3,4})p(?:$|[/_.-])/i.exec(name);
1000
+ return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
1001
+ }
1002
+ function lowestServedVideoRendition(catalog, endpoints) {
1003
+ const renditions = catalog?.video?.renditions ?? {};
1004
+ const servedTracks = new Set(endpoints.flatMap((endpoint) => endpoint.tracks?.map((track) => track.name) ?? []));
1005
+ let selected = "";
1006
+ let selectedRank = Number.POSITIVE_INFINITY;
1007
+ for (const [name, config] of Object.entries(renditions)) {
1008
+ if (!name || !servedTracks.has(name)) continue;
1009
+ const rank = videoRenditionRank(config, name);
1010
+ if (!selected || rank < selectedRank) {
1011
+ selected = name;
1012
+ selectedRank = rank;
1013
+ }
1014
+ }
1015
+ return selected;
1016
+ }
1017
+
1018
+ // src/packet-batch.ts
1019
+ function subscriptionDeliversPacketBatches(subscription) {
1020
+ return subscription.eventTypes?.includes("packets") === true;
1021
+ }
358
1022
 
359
1023
  // src/multicast-subscription-pool.ts
360
1024
  var MulticastSubscriptionPool = class {
@@ -420,19 +1084,28 @@
420
1084
  return this.#entries.size;
421
1085
  }
422
1086
  async #createEntry(gateway, endpoint, onClosed) {
423
- const subscription = await gateway.subscribe({
1087
+ const subscription = await gateway.subscribe(toWindowMulticastSubscribeConfig({
424
1088
  group: endpoint.groupAddress,
425
1089
  ...endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {},
426
1090
  port: endpoint.port,
427
- consume: "bytes",
428
1091
  ...endpoint.mode ? { mode: endpoint.mode } : {},
429
1092
  ...endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {},
430
1093
  ...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}
431
- });
1094
+ }));
432
1095
  const consumers = /* @__PURE__ */ new Map();
433
1096
  let entry;
434
- const bytesListener = (event) => {
435
- this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
1097
+ const packetListener = subscriptionDeliversPacketBatches(subscription) ? {
1098
+ type: "packets",
1099
+ listener: (event) => {
1100
+ for (const data of event.detail) {
1101
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
1102
+ }
1103
+ }
1104
+ } : {
1105
+ type: "bytes",
1106
+ listener: (event) => {
1107
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
1108
+ }
436
1109
  };
437
1110
  const stateListener = (event) => {
438
1111
  this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
@@ -445,8 +1118,12 @@
445
1118
  const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
446
1119
  this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
447
1120
  };
448
- entry = { subscription, consumers, bytesListener, stateListener, errorListener, closed: false };
449
- subscription.addEventListener("bytes", bytesListener);
1121
+ entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
1122
+ if (packetListener.type === "packets") {
1123
+ subscription.addEventListener("packets", packetListener.listener);
1124
+ } else {
1125
+ subscription.addEventListener("bytes", packetListener.listener);
1126
+ }
450
1127
  subscription.addEventListener("state", stateListener);
451
1128
  subscription.addEventListener("error", errorListener);
452
1129
  return entry;
@@ -461,7 +1138,12 @@
461
1138
  return id;
462
1139
  }
463
1140
  #detach(entry) {
464
- entry.subscription.removeEventListener("bytes", entry.bytesListener);
1141
+ const { packetListener } = entry;
1142
+ if (packetListener.type === "packets") {
1143
+ entry.subscription.removeEventListener("packets", packetListener.listener);
1144
+ } else {
1145
+ entry.subscription.removeEventListener("bytes", packetListener.listener);
1146
+ }
465
1147
  entry.subscription.removeEventListener("state", entry.stateListener);
466
1148
  entry.subscription.removeEventListener("error", entry.errorListener);
467
1149
  }
@@ -477,7 +1159,70 @@
477
1159
  };
478
1160
  var multicastSubscriptionPool = new MulticastSubscriptionPool();
479
1161
 
1162
+ // src/moqt-priority.ts
1163
+ var MAX_MOQT_PRIORITY = 255;
1164
+ function moqtPriority(value, field) {
1165
+ if (!Number.isInteger(value) || value < 0 || value > MAX_MOQT_PRIORITY) {
1166
+ throw new RangeError(`${field} must be an integer in [0, ${MAX_MOQT_PRIORITY}], got ${value}`);
1167
+ }
1168
+ return value;
1169
+ }
1170
+ function moqtSubscriberPriority(value) {
1171
+ return moqtPriority(value, "MOQT subscriber priority");
1172
+ }
1173
+ function moqtPublisherPriority(value) {
1174
+ return moqtPriority(value, "MOQT publisher priority");
1175
+ }
1176
+ var MOQT_SUBSCRIBER_PRIORITY = Object.freeze({
1177
+ catalog: moqtSubscriberPriority(0),
1178
+ /**
1179
+ * @deprecated A repair FETCH's SUBSCRIBER_PRIORITY is derived per request
1180
+ * from the catalog source and repair priorities (draft-ramadan-moq-fec
1181
+ * §11.4.5), not a player constant. Use the `subscriberPriority` that
1182
+ * `@blockcast/mmt-container`'s `wireRepairFetch` puts on each
1183
+ * `RepairFetchRequest` (`repairFetchSubscriberPriority`).
1184
+ */
1185
+ deadlineRepair: moqtSubscriberPriority(0),
1186
+ audio: moqtSubscriberPriority(1),
1187
+ video: moqtSubscriberPriority(2),
1188
+ /**
1189
+ * @deprecated See `deadlineRepair`: use the request's catalog-derived
1190
+ * `RepairFetchRequest.subscriberPriority` (draft-ramadan-moq-fec §11.4.5).
1191
+ */
1192
+ backgroundRepair: moqtSubscriberPriority(3)
1193
+ });
1194
+ var MOQT_PUBLISHER_PRIORITY = Object.freeze({
1195
+ catalog: moqtPublisherPriority(32),
1196
+ source: moqtPublisherPriority(128),
1197
+ repair: moqtPublisherPriority(240)
1198
+ });
1199
+
480
1200
  // src/stall-detector.ts
1201
+ function classifyStall({
1202
+ liveQ = null,
1203
+ decodeCallsDelta = null,
1204
+ fps = null
1205
+ } = {}) {
1206
+ if (liveQ != null && liveQ >= 20) {
1207
+ return {
1208
+ verdict: "DECODE-BOUND",
1209
+ reason: `decode queue backlogged now (liveQ=${liveQ})`
1210
+ };
1211
+ }
1212
+ if (liveQ != null && liveQ <= 2 && decodeCallsDelta != null && decodeCallsDelta <= 1) {
1213
+ return {
1214
+ verdict: "PIPELINE-STARVED",
1215
+ reason: `queue empty (liveQ=${liveQ}) and decode flatlined (\u0394calls=${decodeCallsDelta}) \u2014 nothing reaching the decoder; suspect delivery/FEC/failback, not decode perf`
1216
+ };
1217
+ }
1218
+ if (fps != null && fps < 45) {
1219
+ return {
1220
+ verdict: "STARVED-OR-SLOW",
1221
+ reason: `low decode rate (fps=${fps}) without a queue/flatline signal to disambiguate`
1222
+ };
1223
+ }
1224
+ return { verdict: "INDETERMINATE", reason: "no signal supports a verdict" };
1225
+ }
481
1226
  var TransportStallDetector = class {
482
1227
  constructor(config) {
483
1228
  this.config = config;
@@ -553,13 +1298,39 @@
553
1298
  }
554
1299
  async function wireStarvationDetector(args) {
555
1300
  if (!args.session || args.fecInterleaveMs <= 0 || args.fecK <= 0) return null;
1301
+ const windowMs = args.deliveryWindowMs ?? Math.ceil(args.fecInterleaveMs * 3);
1302
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
1303
+ args.warn?.(`Unable to create starvation detector for ${args.trackName}: invalid delivery window ${windowMs}`);
1304
+ return null;
1305
+ }
556
1306
  const detector = wireStallDetector({
557
1307
  session: args.session,
558
- windowMs: Math.ceil(args.fecInterleaveMs * 3),
1308
+ windowMs,
559
1309
  makeDetector: args.makeDetector
560
1310
  });
561
1311
  if (!detector) args.warn?.(`Unable to create starvation detector for ${args.trackName}`);
562
- return detector;
1312
+ if (!detector || typeof args.session.state !== "string") return detector;
1313
+ let active = false;
1314
+ return {
1315
+ update(snapshot) {
1316
+ const state = args.session.state;
1317
+ const shouldObserve = state === "multicast" || state === "amt" || state === "failing";
1318
+ if (!shouldObserve) {
1319
+ if (active) detector.reset();
1320
+ active = false;
1321
+ return;
1322
+ }
1323
+ if (!active) {
1324
+ detector.reset();
1325
+ active = true;
1326
+ }
1327
+ detector.update(snapshot);
1328
+ },
1329
+ reset() {
1330
+ active = false;
1331
+ detector.reset();
1332
+ }
1333
+ };
563
1334
  }
564
1335
 
565
1336
  // src/driad-discovery.ts
@@ -619,10 +1390,7 @@
619
1390
  if (dohRelay) {
620
1391
  return [dohRelay];
621
1392
  }
622
- return [
623
- { host: "amt-relay.m2icast.net", port: 2268, priority: 10, weight: 100, asn: 0 },
624
- { host: "amt.akamaistream.net", port: 2268, priority: 20, weight: 50, asn: 0 }
625
- ];
1393
+ return [];
626
1394
  }
627
1395
  /**
628
1396
  * Query DNS-over-HTTPS for DRIAD relay based on source address (RFC 8777)
@@ -643,7 +1411,7 @@
643
1411
  if (result.Answer && result.Answer.length > 0) {
644
1412
  return {
645
1413
  host: result.Answer[0].data,
646
- port: 2268,
1414
+ port: DEFAULT_AMT_RELAY_PORT,
647
1415
  priority: 10,
648
1416
  weight: 100,
649
1417
  asn: 0
@@ -713,16 +1481,14 @@
713
1481
  capabilities = null;
714
1482
  subscription = null;
715
1483
  driad = new DRIADDiscovery();
716
- abr;
1484
+ /** Built by `connect()` from the caller's ladder; there is no built-in one. */
1485
+ abr = null;
717
1486
  multicastOrchestrator = null;
718
1487
  onOrchestratorCommand;
719
1488
  connectionGeneration = 0;
720
1489
  onPacket;
721
1490
  onError;
722
1491
  onStateChange;
723
- constructor() {
724
- this.abr = new AdaptiveBitrateController();
725
- }
726
1492
  getWindow() {
727
1493
  return typeof window === "undefined" ? void 0 : window;
728
1494
  }
@@ -773,11 +1539,31 @@
773
1539
  * 1. window.multicast gateway bytes
774
1540
  * 2. AMT proxy (specified or discovered via DRIAD)
775
1541
  * 3. MOQ WebTransport (final fallback)
1542
+ *
1543
+ * The ABR ladder (`abrLadder`) and initial rendition (`initialQuality`)
1544
+ * are checked first, before anything is torn down. A config that fails is
1545
+ * refused with the same shape as an invalid endpoint, the error to the
1546
+ * error callback and `false` resolved; unlike one, it is caught before the
1547
+ * live subscription closes, so a refused reconnect leaves that subscription,
1548
+ * its state and any connect already in flight untouched.
776
1549
  */
777
1550
  async connect(config) {
1551
+ let abr;
1552
+ try {
1553
+ abr = new AdaptiveBitrateController({
1554
+ ladder: config.abrLadder,
1555
+ initialQuality: config.initialQuality?.name
1556
+ });
1557
+ } catch (error) {
1558
+ const refused = error instanceof Error ? error : new Error(String(error));
1559
+ if (this.onError) this.onError(refused);
1560
+ else console.error("[Transport] ABR config refused, not connecting:", refused);
1561
+ return false;
1562
+ }
778
1563
  const generation = ++this.connectionGeneration;
779
1564
  this.subscription?.close();
780
1565
  this.subscription = null;
1566
+ this.abr = null;
781
1567
  this.state = {
782
1568
  mode: "disconnected",
783
1569
  connected: false,
@@ -809,10 +1595,8 @@
809
1595
  };
810
1596
  const caps = await this.detectCapabilities();
811
1597
  if (generation !== this.connectionGeneration) return false;
812
- if (config.initialQuality) {
813
- this.abr = new AdaptiveBitrateController(config.initialQuality);
814
- }
815
- this.abr.setCallback((quality) => {
1598
+ this.abr = abr;
1599
+ abr.setCallback((quality) => {
816
1600
  this.state.quality = quality;
817
1601
  this.subscription?.onqualitychange?.(quality);
818
1602
  this.onStateChange?.(this.state);
@@ -877,7 +1661,7 @@
877
1661
  connected: true,
878
1662
  group: config.group,
879
1663
  source: config.source,
880
- quality: this.abr.getCurrentQuality(),
1664
+ quality: this.requireAbr().getCurrentQuality(),
881
1665
  packetsReceived: 0,
882
1666
  bytesReceived: 0,
883
1667
  lastPacketTime: 0
@@ -923,8 +1707,7 @@
923
1707
  async connectAmtProxy(config, amtRelay, generation) {
924
1708
  try {
925
1709
  const relayHost = amtRelay.includes(":") && !amtRelay.startsWith("[") ? `[${amtRelay}]` : amtRelay;
926
- const relayAuthority = config.amtRelayPort ? `${relayHost}:${config.amtRelayPort}` : relayHost;
927
- const wsUrl = new URL(`wss://${relayAuthority}/amt-proxy`);
1710
+ const wsUrl = new URL(`wss://${relayHost}/amt-proxy`);
928
1711
  wsUrl.searchParams.set("group", config.group);
929
1712
  wsUrl.searchParams.set("source", config.source || "");
930
1713
  wsUrl.searchParams.set("port", String(config.port));
@@ -954,7 +1737,7 @@
954
1737
  group: config.group,
955
1738
  source: config.source,
956
1739
  amtRelay,
957
- quality: this.abr.getCurrentQuality(),
1740
+ quality: this.requireAbr().getCurrentQuality(),
958
1741
  packetsReceived: 0,
959
1742
  bytesReceived: 0,
960
1743
  lastPacketTime: 0
@@ -999,7 +1782,7 @@
999
1782
  this.state.packetsReceived++;
1000
1783
  this.state.bytesReceived += data.byteLength;
1001
1784
  this.state.lastPacketTime = Date.now();
1002
- this.abr.recordSample(data.byteLength, 0);
1785
+ this.abr?.recordSample(data.byteLength, 0);
1003
1786
  this.onPacket?.(data);
1004
1787
  }
1005
1788
  /**
@@ -1009,16 +1792,26 @@
1009
1792
  return { ...this.state };
1010
1793
  }
1011
1794
  /**
1012
- * Get current quality level
1795
+ * The rung of the live connection's `abrLadder` ABR is on; `null` when
1796
+ * there is no ladder (before `connect(config)` succeeds, after
1797
+ * `disconnect()`, or after a reconnect that was torn down and bailed).
1013
1798
  */
1014
1799
  getQuality() {
1015
- return this.abr.getCurrentQuality();
1800
+ return this.abr?.getCurrentQuality() ?? null;
1016
1801
  }
1017
1802
  /**
1018
- * Force quality level
1803
+ * Force the rung named `name` of the live connection's `abrLadder`.
1804
+ * Throws `Error` when there is no ladder (see `getQuality()`), and
1805
+ * `RangeError` when the ladder has no rung of that name; it never falls
1806
+ * back to another rung.
1019
1807
  */
1020
1808
  setQuality(name) {
1021
- this.abr.setQuality(name);
1809
+ this.requireAbr().setQuality(name);
1810
+ }
1811
+ /** The controller `connect()` built; before it there is no ladder to adapt over. */
1812
+ requireAbr() {
1813
+ if (!this.abr) throw new Error("[Transport] ABR has no ladder before connect(config)");
1814
+ return this.abr;
1022
1815
  }
1023
1816
  /** Configures multicast-only ABR while leaving the caller's MoQ ABR untouched. */
1024
1817
  configureMulticastTiers(options, onCommand) {
@@ -1030,6 +1823,9 @@
1030
1823
  recordMulticastGoodput(bitrate) {
1031
1824
  return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onGoodput({ bitrate }));
1032
1825
  }
1826
+ recordMulticastHeadroom(healthy) {
1827
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onHeadroom({ healthy }));
1828
+ }
1033
1829
  recordMulticastTierPacket(packet) {
1034
1830
  return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onTierPacket(packet));
1035
1831
  }
@@ -1061,6 +1857,7 @@
1061
1857
  this.connectionGeneration++;
1062
1858
  this.subscription?.close();
1063
1859
  this.subscription = null;
1860
+ this.abr = null;
1064
1861
  this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
1065
1862
  this.multicastOrchestrator = null;
1066
1863
  this.onOrchestratorCommand = void 0;
@@ -1127,8 +1924,21 @@
1127
1924
  // src/no-modules.ts
1128
1925
  var api = {
1129
1926
  AdaptiveBitrateController,
1927
+ validateQualityLadder,
1928
+ catalogQualityLadder,
1929
+ classifyStall,
1930
+ createMulticastSignalingReconciler,
1931
+ endpointToMulticastConfig,
1932
+ hasMulticastCatalogBaseline,
1933
+ hasUnicastMediaRenditions,
1934
+ isRecoverableMulticastSignalReadError,
1935
+ isMulticastOnlyCatalog,
1130
1936
  MulticastOrchestrator,
1131
1937
  MulticastSubscriptionPool,
1938
+ MOQT_PUBLISHER_PRIORITY,
1939
+ MOQT_SUBSCRIBER_PRIORITY,
1940
+ moqtPublisherPriority,
1941
+ moqtSubscriberPriority,
1132
1942
  TransportManager,
1133
1943
  TransportStallDetector,
1134
1944
  endpointKey,
@@ -1136,11 +1946,22 @@
1136
1946
  isUsableMulticastEndpoint,
1137
1947
  isValidMulticastGroupAddress,
1138
1948
  makeSourceSwitchHandler,
1949
+ mergeUrlMulticastPolicy,
1139
1950
  multicastEndpointKey,
1951
+ normalizeCatalogEndpoints,
1952
+ normalizeMulticastConfig,
1140
1953
  normalizeMulticastEndpoint,
1954
+ readPlayerMulticastConfig,
1141
1955
  readUrlMulticastConfig,
1956
+ toWindowMulticastSubscribeConfig,
1957
+ requiresMulticastCapability,
1142
1958
  runUnicastFallback,
1959
+ sameMulticastConfig,
1960
+ selectEndpointPacketIds,
1961
+ selectMulticastCandidate,
1143
1962
  selectMulticastTier,
1963
+ urlOverrideBlocksMulticastCandidate,
1964
+ withMulticastEndpoints,
1144
1965
  wireStarvationDetector
1145
1966
  };
1146
1967
  Object.defineProperty(globalThis, "__MmtTransport", {