@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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +3 -1
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +9 -1
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/GlassesHotspotLease.d.ts +2 -0
- package/build/services/GlassesHotspotLease.d.ts.map +1 -0
- package/build/services/GlassesHotspotLease.js +13 -0
- package/build/services/GlassesHotspotLease.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +14 -5
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/ManagedWebRtcRelay.d.ts +87 -0
- package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
- package/build/services/ManagedWebRtcRelay.js +239 -0
- package/build/services/ManagedWebRtcRelay.js.map +1 -0
- package/build/services/PhoneStreamCoordinator.d.ts +5 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +131 -68
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +2 -1
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +6 -1
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/package.json +8 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +10 -1
- package/src/services/GlassesHotspotLease.ts +11 -0
- package/src/services/LocalMiniappRuntime.ts +27 -11
- package/src/services/ManagedWebRtcRelay.ts +278 -0
- package/src/services/PhoneStreamCoordinator.ts +152 -70
- 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 &&
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|