@mentra/engine 3.2.0-dev.226 → 3.2.0-dev.227

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 (33) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +3 -1
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +9 -1
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/GlassesHotspotLease.d.ts +2 -0
  8. package/build/services/GlassesHotspotLease.d.ts.map +1 -0
  9. package/build/services/GlassesHotspotLease.js +13 -0
  10. package/build/services/GlassesHotspotLease.js.map +1 -0
  11. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  12. package/build/services/LocalMiniappRuntime.js +14 -5
  13. package/build/services/LocalMiniappRuntime.js.map +1 -1
  14. package/build/services/ManagedWebRtcRelay.d.ts +87 -0
  15. package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
  16. package/build/services/ManagedWebRtcRelay.js +239 -0
  17. package/build/services/ManagedWebRtcRelay.js.map +1 -0
  18. package/build/services/PhoneStreamCoordinator.d.ts +5 -0
  19. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  20. package/build/services/PhoneStreamCoordinator.js +131 -68
  21. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  22. package/build/services/SoftapCallTransport.d.ts +2 -1
  23. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  24. package/build/services/SoftapCallTransport.js +6 -1
  25. package/build/services/SoftapCallTransport.js.map +1 -1
  26. package/package.json +8 -7
  27. package/src/generated/releaseMetadata.ts +5 -5
  28. package/src/services/AcsMeetingService.ts +10 -1
  29. package/src/services/GlassesHotspotLease.ts +11 -0
  30. package/src/services/LocalMiniappRuntime.ts +27 -11
  31. package/src/services/ManagedWebRtcRelay.ts +278 -0
  32. package/src/services/PhoneStreamCoordinator.ts +152 -70
  33. package/src/services/SoftapCallTransport.ts +8 -3
@@ -33,9 +33,11 @@
33
33
  * An explicit stop that could not reach BLE is sent on the next reconnect.
34
34
  */
35
35
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal";
36
+ import { createManagedWebRtcRelay } from "./ManagedWebRtcRelay";
36
37
  import { isGlassesConnected } from "./GlassesReadiness";
37
38
  import { phoneCameraFovCoordinator } from "./PhoneCameraFovCoordinator";
38
39
  import { useGlassesStore } from "../stores/glasses";
40
+ import { BgTimer } from "../utils/timers";
39
41
  import { slimStreamStatusEvent, streamStatusSignature } from "./slimStreamStatus";
40
42
  import { getManagedStreamStatus, provisionManagedStream, teardownManagedStream, } from "./cloudStreamApi";
41
43
  /**
@@ -88,6 +90,7 @@ export class PhoneStreamCoordinator {
88
90
  statusSubscriber = null;
89
91
  idCounter = 0;
90
92
  timings;
93
+ relayFactory;
91
94
  linkSource;
92
95
  pendingCameraChanges;
93
96
  unsubscribeLink = null;
@@ -113,6 +116,7 @@ export class PhoneStreamCoordinator {
113
116
  /** Send full resolvedConfig only once per stream session. */
114
117
  resolvedConfigForwarded = false;
115
118
  constructor(timings = {}, deps = {}) {
119
+ this.relayFactory = deps.relayFactory ?? createManagedWebRtcRelay;
116
120
  this.timings = { ...DEFAULT_TIMINGS, ...timings };
117
121
  this.linkSource = deps.linkSource ?? storeLinkSource;
118
122
  this.pendingCameraChanges = deps.pendingCameraChanges ?? (() => phoneCameraFovCoordinator.whenSettled());
@@ -162,7 +166,8 @@ export class PhoneStreamCoordinator {
162
166
  * check covers the multi-subscriber case.
163
167
  */
164
168
  owns(streamId) {
165
- return this.current !== null && this.current.streamId === streamId;
169
+ return (this.current !== null &&
170
+ (this.current.streamId === streamId || (this.current.kind === "managed" && !!this.current.relay?.owns(streamId))));
166
171
  }
167
172
  /** Report-safe stream ownership snapshot for incident diagnostics. */
168
173
  getDiagnosticSnapshot() {
@@ -203,6 +208,7 @@ export class PhoneStreamCoordinator {
203
208
  return this.runExclusive(async () => {
204
209
  await cameraReady;
205
210
  this.assertGlassesConnected();
211
+ await this.flushPendingBleStop();
206
212
  if (this.current) {
207
213
  throw new StreamConflictError("STREAM_ALREADY_ACTIVE", `A ${this.current.kind} stream is already active. Stop it before starting a new one.`);
208
214
  }
@@ -259,12 +265,18 @@ export class PhoneStreamCoordinator {
259
265
  const decision = await this.runExclusive(async () => {
260
266
  await cameraReady;
261
267
  this.assertGlassesConnected();
268
+ await this.flushPendingBleStop();
262
269
  if (this.current && this.current.kind === "unmanaged") {
263
270
  throw new StreamConflictError("STREAM_ALREADY_ACTIVE", "An unmanaged stream is already active. Stop it before starting a managed stream.");
264
271
  }
265
272
  // Join an existing managed stream if one is already running.
266
273
  if (this.current && this.current.kind === "managed") {
267
274
  const existing = this.current;
275
+ if (existing.stopping)
276
+ throw new StreamConflictError("STREAM_CLEANUP_PENDING", "Previous stream cleanup has not completed; retry stop first");
277
+ if (opts.ingest !== undefined && (opts.ingest === "whip") !== (existing.mode === "webrtc")) {
278
+ throw new StreamConflictError("STREAM_MODE_CONFLICT", "Stop the existing stream before switching playback modes");
279
+ }
268
280
  // Restream destinations are immutable after provision — a second
269
281
  // caller trying to dictate destinations on an already-live stream
270
282
  // is a likely bug or a feature we don't yet support.
@@ -280,7 +292,14 @@ export class PhoneStreamCoordinator {
280
292
  // and joins instead of double-provisioning.
281
293
  const provision = await provisionManagedStream(opts.restreamDestinations);
282
294
  const streamId = this.mintId("m");
283
- const ingestUrl = pickIngestUrl(provision, opts.ingest);
295
+ let ingestUrl;
296
+ try {
297
+ ingestUrl = pickIngestUrl(provision, opts.ingest);
298
+ }
299
+ catch (error) {
300
+ await teardownManagedStream(provision.liveInputId).catch(() => undefined);
301
+ throw error;
302
+ }
284
303
  const mode = ingestUrl === provision.webrtcPublishUrl ? "webrtc" : "hls";
285
304
  const entry = {
286
305
  kind: "managed",
@@ -307,17 +326,35 @@ export class PhoneStreamCoordinator {
307
326
  elapsedMs: Date.now() - startupStartedAtMs,
308
327
  });
309
328
  try {
310
- const event = await BluetoothSdk.startStream({
311
- type: "start_stream",
312
- streamUrl: ingestUrl,
313
- streamId,
314
- sound: opts.sound ?? true,
315
- // See startUnmanaged: the native bridge rejects explicit `undefined`.
316
- ...(opts.video !== undefined ? { video: opts.video } : {}),
317
- ...(opts.audio !== undefined ? { audio: opts.audio } : {}),
318
- ...(typeof opts.captureAudio === "boolean" ? { captureAudio: opts.captureAudio } : {}),
319
- });
320
- entry.publisherStart = publisherStartResult(streamId, event);
329
+ if (mode === "webrtc") {
330
+ entry.relay = this.relayFactory({ streamId, ingestUrl, ...opts }, (status, reason) => {
331
+ if (this.current === entry && !entry.stopping)
332
+ this.fanout({ streamId, source: "coordinator", status, data: { reason, transport: "softap_relay" } });
333
+ }, (error) => {
334
+ void this.runExclusive(async () => {
335
+ if (this.current !== entry)
336
+ return;
337
+ this.fanout({ streamId, source: "coordinator", status: "error", data: { reason: error.message } });
338
+ await this.teardownLocked("relay_failed");
339
+ }).catch((cleanupError) => console.warn("[STREAM] relay cleanup failed", cleanupError));
340
+ }, () => this.linkSource.isConnected(), () => {
341
+ this.pendingBleStop = { streamId, hotspot: true };
342
+ this.attachLink();
343
+ });
344
+ }
345
+ const event = entry.relay
346
+ ? await entry.relay.start()
347
+ : await BluetoothSdk.startStream({
348
+ type: "start_stream",
349
+ streamUrl: ingestUrl,
350
+ streamId,
351
+ sound: opts.sound ?? true,
352
+ // See startUnmanaged: the native bridge rejects explicit `undefined`.
353
+ ...(opts.video !== undefined ? { video: opts.video } : {}),
354
+ ...(opts.audio !== undefined ? { audio: opts.audio } : {}),
355
+ ...(typeof opts.captureAudio === "boolean" ? { captureAudio: opts.captureAudio } : {}),
356
+ });
357
+ entry.publisherStart = { ...publisherStartResult(streamId, event), streamId };
321
358
  console.info("[STREAM_STARTUP]", {
322
359
  streamId,
323
360
  stage: "publisher_ready",
@@ -326,8 +363,15 @@ export class PhoneStreamCoordinator {
326
363
  });
327
364
  }
328
365
  catch (err) {
329
- this.current = null;
330
- await teardownManagedStream(provision.liveInputId).catch(() => undefined);
366
+ entry.stopping = true;
367
+ try {
368
+ await entry.relay?.stop();
369
+ this.current = null;
370
+ await this.flushPendingBleStop();
371
+ }
372
+ finally {
373
+ await teardownManagedStream(provision.liveInputId).catch(() => undefined);
374
+ }
331
375
  throw err;
332
376
  }
333
377
  this.startLifecycle(streamId);
@@ -340,6 +384,9 @@ export class PhoneStreamCoordinator {
340
384
  }
341
385
  return { kind: "fresh", entry };
342
386
  });
387
+ if (this.current !== decision.entry || decision.entry.stopping) {
388
+ throw new Error("Stream stopped before playback readiness");
389
+ }
343
390
  if (decision.kind === "join" && decision.immediate) {
344
391
  return decision.immediate;
345
392
  }
@@ -357,6 +404,13 @@ export class PhoneStreamCoordinator {
357
404
  });
358
405
  }
359
406
  async stop(packageName, streamId) {
407
+ // Cancel in-flight native preparation immediately; cleanup remains serialized below.
408
+ const pending = this.current;
409
+ if (pending?.kind === "managed" &&
410
+ (!streamId || pending.streamId === streamId) &&
411
+ pending.subscribers.size === 1 &&
412
+ pending.subscribers.has(packageName))
413
+ pending.relay?.cancel();
360
414
  await this.runExclusive(async () => {
361
415
  if (!this.current)
362
416
  return;
@@ -386,6 +440,10 @@ export class PhoneStreamCoordinator {
386
440
  handleGlassesStatus(event) {
387
441
  if (!this.current)
388
442
  return;
443
+ if (this.current.kind === "managed" && this.current.relay) {
444
+ this.current.relay.handleGlassesStatus(event);
445
+ return;
446
+ }
389
447
  if (event.streamId && event.streamId !== this.current.streamId)
390
448
  return;
391
449
  const includeResolvedConfig = !this.resolvedConfigForwarded && !!event.resolvedConfig;
@@ -415,13 +473,7 @@ export class PhoneStreamCoordinator {
415
473
  if (event.terminal === true || isGiveUp || isStopped) {
416
474
  const reason = isGiveUp ? "glasses_gave_up" : event.status === "error" ? "glasses_error" : "glasses_stopped";
417
475
  const targetStreamId = this.current.streamId;
418
- void this.runExclusive(async () => {
419
- // The stream we wanted to tear down may already be gone (e.g. another
420
- // teardown won the lock and unwound it). Guard before acting.
421
- if (this.current?.streamId !== targetStreamId)
422
- return;
423
- await this.teardownLocked(reason, { sendBleStop: false });
424
- });
476
+ this.requestTeardown(targetStreamId, reason, { sendBleStop: false });
425
477
  }
426
478
  }
427
479
  // ===========================================================================
@@ -444,7 +496,7 @@ export class PhoneStreamCoordinator {
444
496
  this.resumeLocked();
445
497
  }
446
498
  else if (!this.current && this.pendingBleStop) {
447
- this.flushPendingBleStop();
499
+ void this.runExclusive(() => this.flushPendingBleStop()).catch((error) => console.warn("[STREAM] deferred cleanup failed", error));
448
500
  }
449
501
  return;
450
502
  }
@@ -456,7 +508,7 @@ export class PhoneStreamCoordinator {
456
508
  if (!entry)
457
509
  return;
458
510
  const since = Date.now();
459
- const graceTimer = setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs);
511
+ const graceTimer = BgTimer.setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs);
460
512
  this.suspended = { since, graceTimer, publisherGoneProbes: 0 };
461
513
  console.warn("[STREAM] BLE link lost; stream suspended", {
462
514
  streamId: entry.streamId,
@@ -474,7 +526,7 @@ export class PhoneStreamCoordinator {
474
526
  const suspended = this.suspended;
475
527
  if (!entry || !suspended)
476
528
  return;
477
- clearTimeout(suspended.graceTimer);
529
+ BgTimer.clearTimeout(suspended.graceTimer);
478
530
  this.suspended = null;
479
531
  const suspendedMs = Date.now() - suspended.since;
480
532
  console.info("[STREAM] BLE link back; stream resumed", { streamId: entry.streamId, suspendedMs });
@@ -504,21 +556,35 @@ export class PhoneStreamCoordinator {
504
556
  status: "error",
505
557
  data: { reason: LINK_STATUS.reason, teardownReason: reason, ...detail },
506
558
  });
559
+ this.requestTeardown(streamId, reason);
560
+ }
561
+ /** Event/timer failures have no awaiting caller; retain and report cleanup errors locally. */
562
+ requestTeardown(streamId, reason, options = {}) {
507
563
  void this.runExclusive(async () => {
508
564
  if (this.current?.streamId !== streamId)
509
565
  return;
510
- await this.teardownLocked(reason);
566
+ await this.teardownLocked(reason, options);
567
+ }).catch((error) => {
568
+ console.warn("[STREAM] cleanup failed", error);
569
+ if (this.current?.streamId === streamId) {
570
+ this.fanout({ streamId, source: "coordinator", status: "error", data: { reason: "cleanup_failed" } });
571
+ }
511
572
  });
512
573
  }
513
- flushPendingBleStop() {
574
+ async flushPendingBleStop() {
514
575
  const pending = this.pendingBleStop;
515
- if (!pending)
576
+ if (!pending || this.current || !this.linkSource.isConnected())
516
577
  return;
517
- this.pendingBleStop = null;
518
578
  console.info("[STREAM] BLE link back; sending deferred stopStream", pending);
519
- void BluetoothSdk.stopStream()
520
- .catch((err) => console.warn("[STREAM] deferred stopStream failed:", err))
521
- .finally(() => this.detachLinkIfIdle());
579
+ await BluetoothSdk.stopStream();
580
+ if (pending.hotspot) {
581
+ const result = await BluetoothSdk.setHotspotState(false);
582
+ if (result.state !== "disabled")
583
+ throw new Error("Deferred hotspot shutdown was not confirmed");
584
+ }
585
+ if (this.pendingBleStop === pending)
586
+ this.pendingBleStop = null;
587
+ this.detachLinkIfIdle();
522
588
  }
523
589
  // ===========================================================================
524
590
  // Internal
@@ -540,23 +606,25 @@ export class PhoneStreamCoordinator {
540
606
  const connectTimeoutMs = Math.max(1, this.timings.hlsReadinessMaxAttempts * this.timings.hlsReadinessPollMs);
541
607
  const pollingStartedAtMs = Date.now();
542
608
  const scheduleNext = () => {
543
- if (this.current !== entry)
609
+ if (this.current !== entry || entry.stopping)
544
610
  return;
545
611
  const waitingForWebRtc = entry.mode === "webrtc" && !entry.hlsReady;
546
612
  const elapsedMs = Date.now() - pollingStartedAtMs;
547
613
  const remainingMs = Math.max(0, connectTimeoutMs - elapsedMs);
548
614
  const startupDelayMs = Math.min(this.timings.cloudflareStatusPollMs, this.timings.cloudflareStartupPollInitialMs * 2 ** Math.min(Math.max(0, entry.cloudflareAttempts - 1), 10));
549
615
  const delayMs = waitingForWebRtc ? Math.min(startupDelayMs, remainingMs) : this.timings.cloudflareStatusPollMs;
550
- entry.cloudflareTimer = setTimeout(() => void poll(), delayMs);
616
+ entry.cloudflareTimer = BgTimer.setTimeout(() => void poll(), delayMs);
551
617
  };
552
618
  const poll = async () => {
553
- if (this.current !== entry)
619
+ if (this.current !== entry || entry.stopping)
554
620
  return;
555
621
  const requestStartedAtMs = Date.now();
556
622
  let keepPolling = true;
557
623
  entry.cloudflareAttempts += 1;
558
624
  try {
559
625
  const status = await getManagedStreamStatus(entry.liveInputId);
626
+ if (this.current !== entry || entry.stopping)
627
+ return;
560
628
  console.debug("[STREAM_STARTUP]", {
561
629
  streamId: entry.streamId,
562
630
  stage: "cloudflare_probe",
@@ -625,17 +693,15 @@ export class PhoneStreamCoordinator {
625
693
  data: { reason: "webrtc_not_connected" },
626
694
  });
627
695
  const targetStreamId = entry.streamId;
628
- void this.runExclusive(async () => {
629
- if (this.current?.streamId !== targetStreamId)
630
- return;
631
- await this.teardownLocked("webrtc_not_connected");
632
- });
696
+ this.requestTeardown(targetStreamId, "webrtc_not_connected");
633
697
  keepPolling = false;
634
698
  }
635
699
  }
636
700
  }
637
701
  }
638
702
  catch (err) {
703
+ if (this.current !== entry || entry.stopping)
704
+ return;
639
705
  console.warn("[STREAM] cloudflare status poll failed:", err);
640
706
  if (entry.mode === "webrtc" && !entry.hlsReady && Date.now() - pollingStartedAtMs >= connectTimeoutMs) {
641
707
  const timeoutErr = new Error(`WebRTC ingest status could not be confirmed after ${connectTimeoutMs}ms`);
@@ -644,11 +710,7 @@ export class PhoneStreamCoordinator {
644
710
  entry.hlsReadyResolvers = [];
645
711
  entry.hlsReadyRejecters = [];
646
712
  const targetStreamId = entry.streamId;
647
- void this.runExclusive(async () => {
648
- if (this.current?.streamId !== targetStreamId)
649
- return;
650
- await this.teardownLocked("webrtc_status_unavailable");
651
- });
713
+ this.requestTeardown(targetStreamId, "webrtc_status_unavailable");
652
714
  keepPolling = false;
653
715
  }
654
716
  }
@@ -665,7 +727,7 @@ export class PhoneStreamCoordinator {
665
727
  // Skip the first few seconds — Cloudflare doesn't have first-frame yet,
666
728
  // and the HEAD requests would all 404 and burn battery.
667
729
  const tick = async () => {
668
- if (this.current !== entry)
730
+ if (this.current !== entry || entry.stopping)
669
731
  return;
670
732
  entry.hlsAttempts += 1;
671
733
  try {
@@ -673,6 +735,8 @@ export class PhoneStreamCoordinator {
673
735
  // playback edge returns 204 No Content while the input has no
674
736
  // HLS-capable frames (e.g. WebRTC ingest), and 204 is "ok".
675
737
  const res = await fetch(entry.hlsUrl, { method: "HEAD" });
738
+ if (this.current !== entry || entry.stopping)
739
+ return;
676
740
  if (res.status === 200) {
677
741
  entry.hlsReady = true;
678
742
  console.info("[STREAM_STARTUP]", {
@@ -683,7 +747,7 @@ export class PhoneStreamCoordinator {
683
747
  elapsedMs: Date.now() - entry.startupStartedAtMs,
684
748
  });
685
749
  if (entry.hlsTimer) {
686
- clearInterval(entry.hlsTimer);
750
+ BgTimer.clearInterval(entry.hlsTimer);
687
751
  entry.hlsTimer = undefined;
688
752
  }
689
753
  const result = managedStartResult(entry);
@@ -705,7 +769,7 @@ export class PhoneStreamCoordinator {
705
769
  }
706
770
  if (entry.hlsAttempts >= this.timings.hlsReadinessMaxAttempts) {
707
771
  if (entry.hlsTimer) {
708
- clearInterval(entry.hlsTimer);
772
+ BgTimer.clearInterval(entry.hlsTimer);
709
773
  entry.hlsTimer = undefined;
710
774
  }
711
775
  const err = new Error(`HLS playback URL did not become ready after ${this.timings.hlsReadinessMaxAttempts} attempts`);
@@ -720,18 +784,14 @@ export class PhoneStreamCoordinator {
720
784
  data: { reason: "hls_not_ready" },
721
785
  });
722
786
  const targetStreamId = entry.streamId;
723
- void this.runExclusive(async () => {
724
- if (this.current?.streamId !== targetStreamId)
725
- return;
726
- await this.teardownLocked("hls_not_ready");
727
- });
787
+ this.requestTeardown(targetStreamId, "hls_not_ready");
728
788
  }
729
789
  };
730
- setTimeout(() => {
790
+ BgTimer.setTimeout(() => {
731
791
  // Guard: stream may have been torn down during the initial delay.
732
- if (this.current !== entry)
792
+ if (this.current !== entry || entry.stopping)
733
793
  return;
734
- entry.hlsTimer = setInterval(tick, this.timings.hlsReadinessPollMs);
794
+ entry.hlsTimer = BgTimer.setInterval(tick, this.timings.hlsReadinessPollMs);
735
795
  }, this.timings.hlsReadinessInitialDelayMs);
736
796
  }
737
797
  fanout(update) {
@@ -762,24 +822,25 @@ export class PhoneStreamCoordinator {
762
822
  this.lastFanoutSignature = null;
763
823
  this.resolvedConfigForwarded = false;
764
824
  if (this.suspended) {
765
- clearTimeout(this.suspended.graceTimer);
825
+ BgTimer.clearTimeout(this.suspended.graceTimer);
766
826
  this.suspended = null;
767
827
  }
768
828
  // With the link down a BLE write can only fail (and hold the transition
769
829
  // lock for the native timeout). Defer it to the next reconnect instead.
770
830
  const linkUp = this.linkSource.isConnected();
771
831
  if (sendBleStop && !linkUp) {
772
- this.pendingBleStop = { streamId: entry.streamId };
832
+ this.pendingBleStop = { streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay };
773
833
  console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
774
834
  streamId: entry.streamId,
775
835
  reason,
776
836
  });
777
837
  }
778
838
  if (entry.kind === "managed") {
839
+ entry.stopping = true;
779
840
  if (entry.cloudflareTimer)
780
- clearTimeout(entry.cloudflareTimer);
841
+ BgTimer.clearTimeout(entry.cloudflareTimer);
781
842
  if (entry.hlsTimer)
782
- clearInterval(entry.hlsTimer);
843
+ BgTimer.clearInterval(entry.hlsTimer);
783
844
  // Reject any still-pending HLS readiness waiters.
784
845
  const pendingErr = new Error(`Stream torn down: ${reason}`);
785
846
  for (const reject of entry.hlsReadyRejecters)
@@ -787,8 +848,12 @@ export class PhoneStreamCoordinator {
787
848
  entry.hlsReadyResolvers = [];
788
849
  entry.hlsReadyRejecters = [];
789
850
  }
851
+ // Relay stop owns the BLE publisher, native peers and hotspot. Keep the entry on failure
852
+ // so another start cannot acquire resources whose teardown has not been confirmed.
853
+ if (entry.kind === "managed" && entry.relay)
854
+ await entry.relay.stop();
790
855
  try {
791
- if (sendBleStop && linkUp) {
856
+ if (sendBleStop && linkUp && !(entry.kind === "managed" && entry.relay)) {
792
857
  await BluetoothSdk.stopStream();
793
858
  }
794
859
  }
@@ -802,7 +867,6 @@ export class PhoneStreamCoordinator {
802
867
  // serializes us, so this should always be true).
803
868
  if (this.current === entry)
804
869
  this.current = null;
805
- this.detachLinkIfIdle();
806
870
  if (entry.kind === "managed") {
807
871
  // Start remote cleanup only after the publisher has stopped, but do not
808
872
  // hold the local transition lock on an unbounded network request. The
@@ -812,13 +876,16 @@ export class PhoneStreamCoordinator {
812
876
  console.warn("[STREAM] teardownManagedStream failed:", err);
813
877
  });
814
878
  }
879
+ // BLE can recover while native cleanup is draining; no second link event is required.
880
+ await this.flushPendingBleStop();
881
+ this.detachLinkIfIdle();
815
882
  }
816
883
  }
817
884
  }
818
885
  function pickIngestUrl(p, preference) {
819
886
  // Glasses' StreamCommandHandler detects protocol from URL prefix.
820
887
  //
821
- // Default priority: SRT > RTMP > WHIP. SRT first: Cloudflare's WebRTC (WHIP)
888
+ // Default priority: SRT > RTMP. WHIP is explicit. SRT first: Cloudflare's WebRTC (WHIP)
822
889
  // ingest does NOT feed HLS/DASH playback or recording — a WHIP-ingested
823
890
  // managed stream reports "connected" while its hlsUrl serves 204 forever,
824
891
  // which breaks the managed contract (subscribers share HLS playback). SRT
@@ -832,11 +899,7 @@ function pickIngestUrl(p, preference) {
832
899
  //
833
900
  // Throw if none resolved so the caller's Promise rejects with a clear
834
901
  // message rather than the glasses' "unknown protocol" error.
835
- const url = preference === "whip"
836
- ? p.webrtcPublishUrl || p.srtUrl || p.rtmpUrl
837
- : preference === "rtmp"
838
- ? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
839
- : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl;
902
+ const url = preference === "whip" ? p.webrtcPublishUrl : preference === "rtmp" ? p.rtmpUrl || p.srtUrl : p.srtUrl || p.rtmpUrl;
840
903
  if (!url) {
841
904
  throw new Error("Cloudflare provision returned no usable ingest URL");
842
905
  }