@mentra/engine 3.2.0-dev.225 → 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
|
@@ -35,10 +35,12 @@
|
|
|
35
35
|
|
|
36
36
|
import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
|
|
37
37
|
import type {StreamResolvedConfig, StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
|
|
38
|
+
import {createManagedWebRtcRelay, type ManagedRelay} from "./ManagedWebRtcRelay"
|
|
38
39
|
import {isGlassesConnected} from "./GlassesReadiness"
|
|
39
40
|
import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
|
|
40
41
|
import {useGlassesStore} from "../stores/glasses"
|
|
41
42
|
|
|
43
|
+
import {BgTimer} from "../utils/timers"
|
|
42
44
|
import {slimStreamStatusEvent, streamStatusSignature} from "./slimStreamStatus"
|
|
43
45
|
import {
|
|
44
46
|
getManagedStreamStatus,
|
|
@@ -176,13 +178,15 @@ interface ManagedEntry {
|
|
|
176
178
|
hlsUrl: string
|
|
177
179
|
dashUrl: string
|
|
178
180
|
webrtcUrl?: string
|
|
181
|
+
stopping?: boolean
|
|
182
|
+
relay?: ManagedRelay
|
|
179
183
|
publisherStart?: StreamPublisherStartResult
|
|
180
184
|
subscribers: Set<string>
|
|
181
185
|
hlsReady: boolean
|
|
182
186
|
hlsReadyResolvers: Array<(result: ManagedStartResult) => void>
|
|
183
187
|
hlsReadyRejecters: Array<(err: Error) => void>
|
|
184
|
-
cloudflareTimer?: ReturnType<typeof setTimeout>
|
|
185
|
-
hlsTimer?: ReturnType<typeof setInterval>
|
|
188
|
+
cloudflareTimer?: ReturnType<typeof BgTimer.setTimeout>
|
|
189
|
+
hlsTimer?: ReturnType<typeof BgTimer.setInterval>
|
|
186
190
|
hlsAttempts: number
|
|
187
191
|
/** Cloudflare status probes made during this stream session. */
|
|
188
192
|
cloudflareAttempts: number
|
|
@@ -208,7 +212,7 @@ export class StreamConflictError extends Error {
|
|
|
208
212
|
|
|
209
213
|
interface SuspendedState {
|
|
210
214
|
since: number
|
|
211
|
-
graceTimer: ReturnType<typeof setTimeout>
|
|
215
|
+
graceTimer: ReturnType<typeof BgTimer.setTimeout>
|
|
212
216
|
/** Consecutive Cloudflare probes that saw no publisher during this suspension. */
|
|
213
217
|
publisherGoneProbes: number
|
|
214
218
|
}
|
|
@@ -218,6 +222,7 @@ export class PhoneStreamCoordinator {
|
|
|
218
222
|
private statusSubscriber: StatusSubscriber | null = null
|
|
219
223
|
private idCounter = 0
|
|
220
224
|
private readonly timings: TimingConfig
|
|
225
|
+
private readonly relayFactory: typeof createManagedWebRtcRelay
|
|
221
226
|
private readonly linkSource: GlassesLinkSource
|
|
222
227
|
private readonly pendingCameraChanges: () => Promise<void>
|
|
223
228
|
private unsubscribeLink: (() => void) | null = null
|
|
@@ -228,7 +233,7 @@ export class PhoneStreamCoordinator {
|
|
|
228
233
|
* the slot) so a publisher that outlived its input does not keep pushing
|
|
229
234
|
* until its own watchdog fires.
|
|
230
235
|
*/
|
|
231
|
-
private pendingBleStop: {streamId: string} | null = null
|
|
236
|
+
private pendingBleStop: {streamId: string; hotspot?: boolean} | null = null
|
|
232
237
|
/**
|
|
233
238
|
* Serializes state transitions (start, stop, teardown). Without it, a
|
|
234
239
|
* second `start*` racing with the first can pass the `this.current === null`
|
|
@@ -245,8 +250,13 @@ export class PhoneStreamCoordinator {
|
|
|
245
250
|
|
|
246
251
|
constructor(
|
|
247
252
|
timings: CoordinatorTimings = {},
|
|
248
|
-
deps: {
|
|
253
|
+
deps: {
|
|
254
|
+
linkSource?: GlassesLinkSource
|
|
255
|
+
pendingCameraChanges?: () => Promise<void>
|
|
256
|
+
relayFactory?: typeof createManagedWebRtcRelay
|
|
257
|
+
} = {},
|
|
249
258
|
) {
|
|
259
|
+
this.relayFactory = deps.relayFactory ?? createManagedWebRtcRelay
|
|
250
260
|
this.timings = {...DEFAULT_TIMINGS, ...timings}
|
|
251
261
|
this.linkSource = deps.linkSource ?? storeLinkSource
|
|
252
262
|
this.pendingCameraChanges = deps.pendingCameraChanges ?? (() => phoneCameraFovCoordinator.whenSettled())
|
|
@@ -300,7 +310,10 @@ export class PhoneStreamCoordinator {
|
|
|
300
310
|
* check covers the multi-subscriber case.
|
|
301
311
|
*/
|
|
302
312
|
owns(streamId: string): boolean {
|
|
303
|
-
return
|
|
313
|
+
return (
|
|
314
|
+
this.current !== null &&
|
|
315
|
+
(this.current.streamId === streamId || (this.current.kind === "managed" && !!this.current.relay?.owns(streamId)))
|
|
316
|
+
)
|
|
304
317
|
}
|
|
305
318
|
|
|
306
319
|
/** Report-safe stream ownership snapshot for incident diagnostics. */
|
|
@@ -342,6 +355,7 @@ export class PhoneStreamCoordinator {
|
|
|
342
355
|
return this.runExclusive(async () => {
|
|
343
356
|
await cameraReady
|
|
344
357
|
this.assertGlassesConnected()
|
|
358
|
+
await this.flushPendingBleStop()
|
|
345
359
|
if (this.current) {
|
|
346
360
|
throw new StreamConflictError(
|
|
347
361
|
"STREAM_ALREADY_ACTIVE",
|
|
@@ -407,6 +421,7 @@ export class PhoneStreamCoordinator {
|
|
|
407
421
|
const decision = await this.runExclusive(async (): Promise<JoinDecision> => {
|
|
408
422
|
await cameraReady
|
|
409
423
|
this.assertGlassesConnected()
|
|
424
|
+
await this.flushPendingBleStop()
|
|
410
425
|
if (this.current && this.current.kind === "unmanaged") {
|
|
411
426
|
throw new StreamConflictError(
|
|
412
427
|
"STREAM_ALREADY_ACTIVE",
|
|
@@ -417,6 +432,17 @@ export class PhoneStreamCoordinator {
|
|
|
417
432
|
// Join an existing managed stream if one is already running.
|
|
418
433
|
if (this.current && this.current.kind === "managed") {
|
|
419
434
|
const existing = this.current
|
|
435
|
+
if (existing.stopping)
|
|
436
|
+
throw new StreamConflictError(
|
|
437
|
+
"STREAM_CLEANUP_PENDING",
|
|
438
|
+
"Previous stream cleanup has not completed; retry stop first",
|
|
439
|
+
)
|
|
440
|
+
if (opts.ingest !== undefined && (opts.ingest === "whip") !== (existing.mode === "webrtc")) {
|
|
441
|
+
throw new StreamConflictError(
|
|
442
|
+
"STREAM_MODE_CONFLICT",
|
|
443
|
+
"Stop the existing stream before switching playback modes",
|
|
444
|
+
)
|
|
445
|
+
}
|
|
420
446
|
// Restream destinations are immutable after provision — a second
|
|
421
447
|
// caller trying to dictate destinations on an already-live stream
|
|
422
448
|
// is a likely bug or a feature we don't yet support.
|
|
@@ -436,7 +462,13 @@ export class PhoneStreamCoordinator {
|
|
|
436
462
|
// and joins instead of double-provisioning.
|
|
437
463
|
const provision = await provisionManagedStream(opts.restreamDestinations)
|
|
438
464
|
const streamId = this.mintId("m")
|
|
439
|
-
|
|
465
|
+
let ingestUrl: string
|
|
466
|
+
try {
|
|
467
|
+
ingestUrl = pickIngestUrl(provision, opts.ingest)
|
|
468
|
+
} catch (error) {
|
|
469
|
+
await teardownManagedStream(provision.liveInputId).catch(() => undefined)
|
|
470
|
+
throw error
|
|
471
|
+
}
|
|
440
472
|
const mode: ManagedEntry["mode"] = ingestUrl === provision.webrtcPublishUrl ? "webrtc" : "hls"
|
|
441
473
|
|
|
442
474
|
const entry: ManagedEntry = {
|
|
@@ -466,17 +498,40 @@ export class PhoneStreamCoordinator {
|
|
|
466
498
|
})
|
|
467
499
|
|
|
468
500
|
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
501
|
+
if (mode === "webrtc") {
|
|
502
|
+
entry.relay = this.relayFactory(
|
|
503
|
+
{streamId, ingestUrl, ...opts},
|
|
504
|
+
(status, reason) => {
|
|
505
|
+
if (this.current === entry && !entry.stopping)
|
|
506
|
+
this.fanout({streamId, source: "coordinator", status, data: {reason, transport: "softap_relay"}})
|
|
507
|
+
},
|
|
508
|
+
(error) => {
|
|
509
|
+
void this.runExclusive(async () => {
|
|
510
|
+
if (this.current !== entry) return
|
|
511
|
+
this.fanout({streamId, source: "coordinator", status: "error", data: {reason: error.message}})
|
|
512
|
+
await this.teardownLocked("relay_failed")
|
|
513
|
+
}).catch((cleanupError) => console.warn("[STREAM] relay cleanup failed", cleanupError))
|
|
514
|
+
},
|
|
515
|
+
() => this.linkSource.isConnected(),
|
|
516
|
+
() => {
|
|
517
|
+
this.pendingBleStop = {streamId, hotspot: true}
|
|
518
|
+
this.attachLink()
|
|
519
|
+
},
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
const event = entry.relay
|
|
523
|
+
? await entry.relay.start()
|
|
524
|
+
: await BluetoothSdk.startStream({
|
|
525
|
+
type: "start_stream",
|
|
526
|
+
streamUrl: ingestUrl,
|
|
527
|
+
streamId,
|
|
528
|
+
sound: opts.sound ?? true,
|
|
529
|
+
// See startUnmanaged: the native bridge rejects explicit `undefined`.
|
|
530
|
+
...(opts.video !== undefined ? {video: opts.video} : {}),
|
|
531
|
+
...(opts.audio !== undefined ? {audio: opts.audio} : {}),
|
|
532
|
+
...(typeof opts.captureAudio === "boolean" ? {captureAudio: opts.captureAudio} : {}),
|
|
533
|
+
})
|
|
534
|
+
entry.publisherStart = {...publisherStartResult(streamId, event), streamId}
|
|
480
535
|
console.info("[STREAM_STARTUP]", {
|
|
481
536
|
streamId,
|
|
482
537
|
stage: "publisher_ready",
|
|
@@ -484,8 +539,14 @@ export class PhoneStreamCoordinator {
|
|
|
484
539
|
elapsedMs: Date.now() - startupStartedAtMs,
|
|
485
540
|
})
|
|
486
541
|
} catch (err) {
|
|
487
|
-
|
|
488
|
-
|
|
542
|
+
entry.stopping = true
|
|
543
|
+
try {
|
|
544
|
+
await entry.relay?.stop()
|
|
545
|
+
this.current = null
|
|
546
|
+
await this.flushPendingBleStop()
|
|
547
|
+
} finally {
|
|
548
|
+
await teardownManagedStream(provision.liveInputId).catch(() => undefined)
|
|
549
|
+
}
|
|
489
550
|
throw err
|
|
490
551
|
}
|
|
491
552
|
|
|
@@ -500,6 +561,9 @@ export class PhoneStreamCoordinator {
|
|
|
500
561
|
return {kind: "fresh", entry}
|
|
501
562
|
})
|
|
502
563
|
|
|
564
|
+
if (this.current !== decision.entry || decision.entry.stopping) {
|
|
565
|
+
throw new Error("Stream stopped before playback readiness")
|
|
566
|
+
}
|
|
503
567
|
if (decision.kind === "join" && decision.immediate) {
|
|
504
568
|
return decision.immediate
|
|
505
569
|
}
|
|
@@ -520,6 +584,15 @@ export class PhoneStreamCoordinator {
|
|
|
520
584
|
}
|
|
521
585
|
|
|
522
586
|
async stop(packageName: string, streamId?: string): Promise<void> {
|
|
587
|
+
// Cancel in-flight native preparation immediately; cleanup remains serialized below.
|
|
588
|
+
const pending = this.current
|
|
589
|
+
if (
|
|
590
|
+
pending?.kind === "managed" &&
|
|
591
|
+
(!streamId || pending.streamId === streamId) &&
|
|
592
|
+
pending.subscribers.size === 1 &&
|
|
593
|
+
pending.subscribers.has(packageName)
|
|
594
|
+
)
|
|
595
|
+
pending.relay?.cancel()
|
|
523
596
|
await this.runExclusive(async () => {
|
|
524
597
|
if (!this.current) return
|
|
525
598
|
|
|
@@ -549,6 +622,10 @@ export class PhoneStreamCoordinator {
|
|
|
549
622
|
*/
|
|
550
623
|
handleGlassesStatus(event: StreamStatusEvent): void {
|
|
551
624
|
if (!this.current) return
|
|
625
|
+
if (this.current.kind === "managed" && this.current.relay) {
|
|
626
|
+
this.current.relay.handleGlassesStatus(event)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
552
629
|
if (event.streamId && event.streamId !== this.current.streamId) return
|
|
553
630
|
|
|
554
631
|
const includeResolvedConfig = !this.resolvedConfigForwarded && !!event.resolvedConfig
|
|
@@ -579,12 +656,7 @@ export class PhoneStreamCoordinator {
|
|
|
579
656
|
if (event.terminal === true || isGiveUp || isStopped) {
|
|
580
657
|
const reason = isGiveUp ? "glasses_gave_up" : event.status === "error" ? "glasses_error" : "glasses_stopped"
|
|
581
658
|
const targetStreamId = this.current.streamId
|
|
582
|
-
|
|
583
|
-
// The stream we wanted to tear down may already be gone (e.g. another
|
|
584
|
-
// teardown won the lock and unwound it). Guard before acting.
|
|
585
|
-
if (this.current?.streamId !== targetStreamId) return
|
|
586
|
-
await this.teardownLocked(reason, {sendBleStop: false})
|
|
587
|
-
})
|
|
659
|
+
this.requestTeardown(targetStreamId, reason, {sendBleStop: false})
|
|
588
660
|
}
|
|
589
661
|
}
|
|
590
662
|
|
|
@@ -608,7 +680,9 @@ export class PhoneStreamCoordinator {
|
|
|
608
680
|
if (this.current && this.suspended) {
|
|
609
681
|
this.resumeLocked()
|
|
610
682
|
} else if (!this.current && this.pendingBleStop) {
|
|
611
|
-
this.flushPendingBleStop()
|
|
683
|
+
void this.runExclusive(() => this.flushPendingBleStop()).catch((error) =>
|
|
684
|
+
console.warn("[STREAM] deferred cleanup failed", error),
|
|
685
|
+
)
|
|
612
686
|
}
|
|
613
687
|
return
|
|
614
688
|
}
|
|
@@ -619,7 +693,7 @@ export class PhoneStreamCoordinator {
|
|
|
619
693
|
const entry = this.current
|
|
620
694
|
if (!entry) return
|
|
621
695
|
const since = Date.now()
|
|
622
|
-
const graceTimer = setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs)
|
|
696
|
+
const graceTimer = BgTimer.setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs)
|
|
623
697
|
this.suspended = {since, graceTimer, publisherGoneProbes: 0}
|
|
624
698
|
console.warn("[STREAM] BLE link lost; stream suspended", {
|
|
625
699
|
streamId: entry.streamId,
|
|
@@ -637,7 +711,7 @@ export class PhoneStreamCoordinator {
|
|
|
637
711
|
const entry = this.current
|
|
638
712
|
const suspended = this.suspended
|
|
639
713
|
if (!entry || !suspended) return
|
|
640
|
-
clearTimeout(suspended.graceTimer)
|
|
714
|
+
BgTimer.clearTimeout(suspended.graceTimer)
|
|
641
715
|
this.suspended = null
|
|
642
716
|
const suspendedMs = Date.now() - suspended.since
|
|
643
717
|
console.info("[STREAM] BLE link back; stream resumed", {streamId: entry.streamId, suspendedMs})
|
|
@@ -668,20 +742,33 @@ export class PhoneStreamCoordinator {
|
|
|
668
742
|
status: "error",
|
|
669
743
|
data: {reason: LINK_STATUS.reason, teardownReason: reason, ...detail},
|
|
670
744
|
})
|
|
745
|
+
this.requestTeardown(streamId, reason)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Event/timer failures have no awaiting caller; retain and report cleanup errors locally. */
|
|
749
|
+
private requestTeardown(streamId: string, reason: string, options: {sendBleStop?: boolean} = {}): void {
|
|
671
750
|
void this.runExclusive(async () => {
|
|
672
751
|
if (this.current?.streamId !== streamId) return
|
|
673
|
-
await this.teardownLocked(reason)
|
|
752
|
+
await this.teardownLocked(reason, options)
|
|
753
|
+
}).catch((error) => {
|
|
754
|
+
console.warn("[STREAM] cleanup failed", error)
|
|
755
|
+
if (this.current?.streamId === streamId) {
|
|
756
|
+
this.fanout({streamId, source: "coordinator", status: "error", data: {reason: "cleanup_failed"}})
|
|
757
|
+
}
|
|
674
758
|
})
|
|
675
759
|
}
|
|
676
760
|
|
|
677
|
-
private flushPendingBleStop(): void {
|
|
761
|
+
private async flushPendingBleStop(): Promise<void> {
|
|
678
762
|
const pending = this.pendingBleStop
|
|
679
|
-
if (!pending) return
|
|
680
|
-
this.pendingBleStop = null
|
|
763
|
+
if (!pending || this.current || !this.linkSource.isConnected()) return
|
|
681
764
|
console.info("[STREAM] BLE link back; sending deferred stopStream", pending)
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
765
|
+
await BluetoothSdk.stopStream()
|
|
766
|
+
if (pending.hotspot) {
|
|
767
|
+
const result = await BluetoothSdk.setHotspotState(false)
|
|
768
|
+
if (result.state !== "disabled") throw new Error("Deferred hotspot shutdown was not confirmed")
|
|
769
|
+
}
|
|
770
|
+
if (this.pendingBleStop === pending) this.pendingBleStop = null
|
|
771
|
+
this.detachLinkIfIdle()
|
|
685
772
|
}
|
|
686
773
|
|
|
687
774
|
// ===========================================================================
|
|
@@ -708,7 +795,7 @@ export class PhoneStreamCoordinator {
|
|
|
708
795
|
const pollingStartedAtMs = Date.now()
|
|
709
796
|
|
|
710
797
|
const scheduleNext = () => {
|
|
711
|
-
if (this.current !== entry) return
|
|
798
|
+
if (this.current !== entry || entry.stopping) return
|
|
712
799
|
const waitingForWebRtc = entry.mode === "webrtc" && !entry.hlsReady
|
|
713
800
|
const elapsedMs = Date.now() - pollingStartedAtMs
|
|
714
801
|
const remainingMs = Math.max(0, connectTimeoutMs - elapsedMs)
|
|
@@ -717,16 +804,17 @@ export class PhoneStreamCoordinator {
|
|
|
717
804
|
this.timings.cloudflareStartupPollInitialMs * 2 ** Math.min(Math.max(0, entry.cloudflareAttempts - 1), 10),
|
|
718
805
|
)
|
|
719
806
|
const delayMs = waitingForWebRtc ? Math.min(startupDelayMs, remainingMs) : this.timings.cloudflareStatusPollMs
|
|
720
|
-
entry.cloudflareTimer = setTimeout(() => void poll(), delayMs)
|
|
807
|
+
entry.cloudflareTimer = BgTimer.setTimeout(() => void poll(), delayMs)
|
|
721
808
|
}
|
|
722
809
|
|
|
723
810
|
const poll = async () => {
|
|
724
|
-
if (this.current !== entry) return
|
|
811
|
+
if (this.current !== entry || entry.stopping) return
|
|
725
812
|
const requestStartedAtMs = Date.now()
|
|
726
813
|
let keepPolling = true
|
|
727
814
|
entry.cloudflareAttempts += 1
|
|
728
815
|
try {
|
|
729
816
|
const status: CloudflareStatus = await getManagedStreamStatus(entry.liveInputId)
|
|
817
|
+
if (this.current !== entry || entry.stopping) return
|
|
730
818
|
console.debug("[STREAM_STARTUP]", {
|
|
731
819
|
streamId: entry.streamId,
|
|
732
820
|
stage: "cloudflare_probe",
|
|
@@ -791,15 +879,13 @@ export class PhoneStreamCoordinator {
|
|
|
791
879
|
data: {reason: "webrtc_not_connected"},
|
|
792
880
|
})
|
|
793
881
|
const targetStreamId = entry.streamId
|
|
794
|
-
|
|
795
|
-
if (this.current?.streamId !== targetStreamId) return
|
|
796
|
-
await this.teardownLocked("webrtc_not_connected")
|
|
797
|
-
})
|
|
882
|
+
this.requestTeardown(targetStreamId, "webrtc_not_connected")
|
|
798
883
|
keepPolling = false
|
|
799
884
|
}
|
|
800
885
|
}
|
|
801
886
|
}
|
|
802
887
|
} catch (err) {
|
|
888
|
+
if (this.current !== entry || entry.stopping) return
|
|
803
889
|
console.warn("[STREAM] cloudflare status poll failed:", err)
|
|
804
890
|
if (entry.mode === "webrtc" && !entry.hlsReady && Date.now() - pollingStartedAtMs >= connectTimeoutMs) {
|
|
805
891
|
const timeoutErr = new Error(`WebRTC ingest status could not be confirmed after ${connectTimeoutMs}ms`)
|
|
@@ -807,10 +893,7 @@ export class PhoneStreamCoordinator {
|
|
|
807
893
|
entry.hlsReadyResolvers = []
|
|
808
894
|
entry.hlsReadyRejecters = []
|
|
809
895
|
const targetStreamId = entry.streamId
|
|
810
|
-
|
|
811
|
-
if (this.current?.streamId !== targetStreamId) return
|
|
812
|
-
await this.teardownLocked("webrtc_status_unavailable")
|
|
813
|
-
})
|
|
896
|
+
this.requestTeardown(targetStreamId, "webrtc_status_unavailable")
|
|
814
897
|
keepPolling = false
|
|
815
898
|
}
|
|
816
899
|
} finally {
|
|
@@ -827,13 +910,14 @@ export class PhoneStreamCoordinator {
|
|
|
827
910
|
// Skip the first few seconds — Cloudflare doesn't have first-frame yet,
|
|
828
911
|
// and the HEAD requests would all 404 and burn battery.
|
|
829
912
|
const tick = async () => {
|
|
830
|
-
if (this.current !== entry) return
|
|
913
|
+
if (this.current !== entry || entry.stopping) return
|
|
831
914
|
entry.hlsAttempts += 1
|
|
832
915
|
try {
|
|
833
916
|
// Require a real manifest (200 with a body), not just res.ok — the
|
|
834
917
|
// playback edge returns 204 No Content while the input has no
|
|
835
918
|
// HLS-capable frames (e.g. WebRTC ingest), and 204 is "ok".
|
|
836
919
|
const res = await fetch(entry.hlsUrl, {method: "HEAD"})
|
|
920
|
+
if (this.current !== entry || entry.stopping) return
|
|
837
921
|
if (res.status === 200) {
|
|
838
922
|
entry.hlsReady = true
|
|
839
923
|
console.info("[STREAM_STARTUP]", {
|
|
@@ -844,7 +928,7 @@ export class PhoneStreamCoordinator {
|
|
|
844
928
|
elapsedMs: Date.now() - entry.startupStartedAtMs,
|
|
845
929
|
})
|
|
846
930
|
if (entry.hlsTimer) {
|
|
847
|
-
clearInterval(entry.hlsTimer)
|
|
931
|
+
BgTimer.clearInterval(entry.hlsTimer)
|
|
848
932
|
entry.hlsTimer = undefined
|
|
849
933
|
}
|
|
850
934
|
const result = managedStartResult(entry)
|
|
@@ -864,7 +948,7 @@ export class PhoneStreamCoordinator {
|
|
|
864
948
|
}
|
|
865
949
|
if (entry.hlsAttempts >= this.timings.hlsReadinessMaxAttempts) {
|
|
866
950
|
if (entry.hlsTimer) {
|
|
867
|
-
clearInterval(entry.hlsTimer)
|
|
951
|
+
BgTimer.clearInterval(entry.hlsTimer)
|
|
868
952
|
entry.hlsTimer = undefined
|
|
869
953
|
}
|
|
870
954
|
const err = new Error(
|
|
@@ -880,16 +964,13 @@ export class PhoneStreamCoordinator {
|
|
|
880
964
|
data: {reason: "hls_not_ready"},
|
|
881
965
|
})
|
|
882
966
|
const targetStreamId = entry.streamId
|
|
883
|
-
|
|
884
|
-
if (this.current?.streamId !== targetStreamId) return
|
|
885
|
-
await this.teardownLocked("hls_not_ready")
|
|
886
|
-
})
|
|
967
|
+
this.requestTeardown(targetStreamId, "hls_not_ready")
|
|
887
968
|
}
|
|
888
969
|
}
|
|
889
|
-
setTimeout(() => {
|
|
970
|
+
BgTimer.setTimeout(() => {
|
|
890
971
|
// Guard: stream may have been torn down during the initial delay.
|
|
891
|
-
if (this.current !== entry) return
|
|
892
|
-
entry.hlsTimer = setInterval(tick, this.timings.hlsReadinessPollMs)
|
|
972
|
+
if (this.current !== entry || entry.stopping) return
|
|
973
|
+
entry.hlsTimer = BgTimer.setInterval(tick, this.timings.hlsReadinessPollMs)
|
|
893
974
|
}, this.timings.hlsReadinessInitialDelayMs)
|
|
894
975
|
}
|
|
895
976
|
|
|
@@ -921,7 +1002,7 @@ export class PhoneStreamCoordinator {
|
|
|
921
1002
|
this.resolvedConfigForwarded = false
|
|
922
1003
|
|
|
923
1004
|
if (this.suspended) {
|
|
924
|
-
clearTimeout(this.suspended.graceTimer)
|
|
1005
|
+
BgTimer.clearTimeout(this.suspended.graceTimer)
|
|
925
1006
|
this.suspended = null
|
|
926
1007
|
}
|
|
927
1008
|
|
|
@@ -929,7 +1010,7 @@ export class PhoneStreamCoordinator {
|
|
|
929
1010
|
// lock for the native timeout). Defer it to the next reconnect instead.
|
|
930
1011
|
const linkUp = this.linkSource.isConnected()
|
|
931
1012
|
if (sendBleStop && !linkUp) {
|
|
932
|
-
this.pendingBleStop = {streamId: entry.streamId}
|
|
1013
|
+
this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay}
|
|
933
1014
|
console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
|
|
934
1015
|
streamId: entry.streamId,
|
|
935
1016
|
reason,
|
|
@@ -937,8 +1018,9 @@ export class PhoneStreamCoordinator {
|
|
|
937
1018
|
}
|
|
938
1019
|
|
|
939
1020
|
if (entry.kind === "managed") {
|
|
940
|
-
|
|
941
|
-
if (entry.
|
|
1021
|
+
entry.stopping = true
|
|
1022
|
+
if (entry.cloudflareTimer) BgTimer.clearTimeout(entry.cloudflareTimer)
|
|
1023
|
+
if (entry.hlsTimer) BgTimer.clearInterval(entry.hlsTimer)
|
|
942
1024
|
// Reject any still-pending HLS readiness waiters.
|
|
943
1025
|
const pendingErr = new Error(`Stream torn down: ${reason}`)
|
|
944
1026
|
for (const reject of entry.hlsReadyRejecters) reject(pendingErr)
|
|
@@ -946,8 +1028,11 @@ export class PhoneStreamCoordinator {
|
|
|
946
1028
|
entry.hlsReadyRejecters = []
|
|
947
1029
|
}
|
|
948
1030
|
|
|
1031
|
+
// Relay stop owns the BLE publisher, native peers and hotspot. Keep the entry on failure
|
|
1032
|
+
// so another start cannot acquire resources whose teardown has not been confirmed.
|
|
1033
|
+
if (entry.kind === "managed" && entry.relay) await entry.relay.stop()
|
|
949
1034
|
try {
|
|
950
|
-
if (sendBleStop && linkUp) {
|
|
1035
|
+
if (sendBleStop && linkUp && !(entry.kind === "managed" && entry.relay)) {
|
|
951
1036
|
await BluetoothSdk.stopStream()
|
|
952
1037
|
}
|
|
953
1038
|
} catch (err) {
|
|
@@ -958,8 +1043,6 @@ export class PhoneStreamCoordinator {
|
|
|
958
1043
|
// Only clear if we're still the active entry (defensive — runExclusive
|
|
959
1044
|
// serializes us, so this should always be true).
|
|
960
1045
|
if (this.current === entry) this.current = null
|
|
961
|
-
this.detachLinkIfIdle()
|
|
962
|
-
|
|
963
1046
|
if (entry.kind === "managed") {
|
|
964
1047
|
// Start remote cleanup only after the publisher has stopped, but do not
|
|
965
1048
|
// hold the local transition lock on an unbounded network request. The
|
|
@@ -969,6 +1052,9 @@ export class PhoneStreamCoordinator {
|
|
|
969
1052
|
console.warn("[STREAM] teardownManagedStream failed:", err)
|
|
970
1053
|
})
|
|
971
1054
|
}
|
|
1055
|
+
// BLE can recover while native cleanup is draining; no second link event is required.
|
|
1056
|
+
await this.flushPendingBleStop()
|
|
1057
|
+
this.detachLinkIfIdle()
|
|
972
1058
|
}
|
|
973
1059
|
}
|
|
974
1060
|
}
|
|
@@ -976,7 +1062,7 @@ export class PhoneStreamCoordinator {
|
|
|
976
1062
|
function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp"): string {
|
|
977
1063
|
// Glasses' StreamCommandHandler detects protocol from URL prefix.
|
|
978
1064
|
//
|
|
979
|
-
// Default priority: SRT > RTMP
|
|
1065
|
+
// Default priority: SRT > RTMP. WHIP is explicit. SRT first: Cloudflare's WebRTC (WHIP)
|
|
980
1066
|
// ingest does NOT feed HLS/DASH playback or recording — a WHIP-ingested
|
|
981
1067
|
// managed stream reports "connected" while its hlsUrl serves 204 forever,
|
|
982
1068
|
// which breaks the managed contract (subscribers share HLS playback). SRT
|
|
@@ -991,11 +1077,7 @@ function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp")
|
|
|
991
1077
|
// Throw if none resolved so the caller's Promise rejects with a clear
|
|
992
1078
|
// message rather than the glasses' "unknown protocol" error.
|
|
993
1079
|
const url =
|
|
994
|
-
preference === "whip"
|
|
995
|
-
? p.webrtcPublishUrl || p.srtUrl || p.rtmpUrl
|
|
996
|
-
: preference === "rtmp"
|
|
997
|
-
? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
|
|
998
|
-
: p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
|
|
1080
|
+
preference === "whip" ? p.webrtcPublishUrl : preference === "rtmp" ? p.rtmpUrl || p.srtUrl : p.srtUrl || p.rtmpUrl
|
|
999
1081
|
if (!url) {
|
|
1000
1082
|
throw new Error("Cloudflare provision returned no usable ingest URL")
|
|
1001
1083
|
}
|
|
@@ -707,8 +707,8 @@ export function createSoftapCallDeps(args: {
|
|
|
707
707
|
/** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
|
|
708
708
|
awaitFirstFrame: () => Promise<void>
|
|
709
709
|
subsystems: {
|
|
710
|
-
setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string}>
|
|
711
|
-
joinScopedNetwork: (ssid: string, passphrase: string) => Promise<string | undefined>
|
|
710
|
+
setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
|
|
711
|
+
joinScopedNetwork: (ssid: string, passphrase: string, gateway?: string) => Promise<string | undefined>
|
|
712
712
|
leaveScopedNetwork: () => Promise<void>
|
|
713
713
|
joinMeeting: (
|
|
714
714
|
packageName: string,
|
|
@@ -767,6 +767,7 @@ export function createSoftapCallDeps(args: {
|
|
|
767
767
|
}): SoftapCallDeps {
|
|
768
768
|
const {packageName, subsystems} = args
|
|
769
769
|
const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS
|
|
770
|
+
let gatewayAddress: string | undefined
|
|
770
771
|
return {
|
|
771
772
|
startHotspot: async (report) => {
|
|
772
773
|
const enable = async () => {
|
|
@@ -778,6 +779,7 @@ export function createSoftapCallDeps(args: {
|
|
|
778
779
|
throw new Error("the glasses hotspot reported no password")
|
|
779
780
|
}
|
|
780
781
|
report?.(`Glasses report hotspot ${status.ssid} enabled`)
|
|
782
|
+
gatewayAddress = status.localIp
|
|
781
783
|
return {ssid: status.ssid, passphrase: status.password}
|
|
782
784
|
}
|
|
783
785
|
try {
|
|
@@ -808,7 +810,9 @@ export function createSoftapCallDeps(args: {
|
|
|
808
810
|
},
|
|
809
811
|
joinScopedNetwork: async (ssid, passphrase, report) => {
|
|
810
812
|
const joinOnce = (nextSsid: string, nextPassphrase: string) =>
|
|
811
|
-
|
|
813
|
+
gatewayAddress
|
|
814
|
+
? subsystems.joinScopedNetwork(nextSsid, nextPassphrase, gatewayAddress)
|
|
815
|
+
: subsystems.joinScopedNetwork(nextSsid, nextPassphrase)
|
|
812
816
|
let address: string | undefined
|
|
813
817
|
try {
|
|
814
818
|
address = await joinOnce(ssid, passphrase)
|
|
@@ -821,6 +825,7 @@ export function createSoftapCallDeps(args: {
|
|
|
821
825
|
await subsystems.setHotspotState(false)
|
|
822
826
|
const status = await subsystems.setHotspotState(true)
|
|
823
827
|
if (status.state !== "enabled" || !status.ssid || !status.password) throw error
|
|
828
|
+
gatewayAddress = status.localIp
|
|
824
829
|
if (hotspotBroadcastWaitMs > 0) {
|
|
825
830
|
report?.(
|
|
826
831
|
`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`,
|