@3dsource/angular-unreal-module 0.0.146-dev.0 → 0.0.146-dev.10

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.
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, DestroyRef, ChangeDetectionStrategy, Component, Injectable, signal, effect, untracked, computed, Pipe, input, ElementRef, viewChild, afterNextRender, HostListener, output, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
3
3
  import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-ui-native';
4
- import { Subject, skip, share, merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, timer, take, defer, switchMap, from, throwError, EMPTY, retry, timeout as timeout$1, repeat, takeUntil as takeUntil$1, fromEvent, tap as tap$1, throttleTime, delay, concat, debounceTime, mergeMap, shareReplay, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
4
+ import { Subject, skip, share, merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, timer, take, defer, switchMap, from, throwError, EMPTY, retry, timeout as timeout$1, repeat, BehaviorSubject, takeUntil as takeUntil$1, fromEvent, tap as tap$1, throttleTime, delay, concat, debounceTime, mergeMap, ignoreElements, shareReplay, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
5
5
  import { createAction, props, createReducer, on, createFeature, Store, createSelector, provideState } from '@ngrx/store';
6
6
  import { whereNot, Falsy, Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, generateUuid, Truthy, tapLog, Semaphore, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
7
7
  import { filter, map as map$1, distinctUntilChanged, takeWhile, takeUntil, catchError, tap, timeout, finalize, first, switchMap as switchMap$1, withLatestFrom, exhaustMap, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
@@ -444,6 +444,7 @@ const initialState = {
444
444
  isAfkTimerVisible: false,
445
445
  afkCountdown: 0,
446
446
  cirrusConnected: false,
447
+ adopted: false,
447
448
  establishingConnection: false,
448
449
  isReconnecting: false,
449
450
  viewportReady: false,
@@ -580,7 +581,11 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
580
581
  },
581
582
  };
582
583
  }), on(initSignalling, (state) => {
583
- return { ...state, disconnectReason: DisconnectReason.None };
584
+ return {
585
+ ...state,
586
+ adopted: false,
587
+ disconnectReason: DisconnectReason.None,
588
+ };
584
589
  }), on(reconnectPeerSuccess, (state) => {
585
590
  return { ...state, isReconnecting: false };
586
591
  }), on(setSignalingName, (state, { instanceName }) => {
@@ -643,10 +648,12 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
643
648
  return { ...state, analyticsEvent: event };
644
649
  }), on(setCirrusConnected, (state) => {
645
650
  return { ...state, cirrusConnected: true };
646
- }), on(streamAdopted, (state, { machineId }) => {
651
+ }), on(streamAdopted, (state, { machineId, ssData }) => {
647
652
  return {
648
653
  ...state,
649
654
  cirrusConnected: true,
655
+ adopted: true,
656
+ ssData: ssData ?? state.ssData,
650
657
  awsInstance: {
651
658
  ...state.awsInstance,
652
659
  wsUrl: machineId,
@@ -676,6 +683,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
676
683
  return {
677
684
  ...state,
678
685
  cirrusConnected: false,
686
+ adopted: false,
679
687
  dataChannelConnected: false,
680
688
  viewportReady: false,
681
689
  isVideoPlaying: false,
@@ -1314,13 +1322,18 @@ const selectTotalProgress = createSelector(unrealFeature.selectStatusPercentSign
1314
1322
  const SIGNALLING_MAX = SIGNALLING_PERCENT_VALUE / 100;
1315
1323
  const ORCHESTRATION_WS_PATTERN = /^wss:\/\/orchestration-websocket/i;
1316
1324
  const isNewOrchestration = ORCHESTRATION_WS_PATTERN.test(awsInstance?.wsUrl ?? '');
1317
- // Orchestration progress is 0–1 from the backend; map to 0–1 directly.
1318
- // Once the data channel is connected, signaling is fully complete (1.0).
1319
- const signalingProgress = isNewOrchestration
1320
- ? dataChannelConnected
1321
- ? 1
1322
- : (awsInstance?.progressComplete ?? 0)
1323
- : (statusPercentSignallingServer ?? 0) / 100;
1325
+ // Once the data channel is connected, signaling is fully complete (1.0) —
1326
+ // regardless of orchestration type. This is what makes an ADOPTED stream
1327
+ // (whose wsUrl is the machine host, not the orchestration-websocket URL,
1328
+ // and which never set `statusPercentSignallingServer`) correctly show the
1329
+ // whole first phase done instead of stalling near the command-only value.
1330
+ // Otherwise: new orchestration reports 0–1 progress; legacy uses the
1331
+ // polling percent.
1332
+ const signalingProgress = dataChannelConnected
1333
+ ? 1
1334
+ : isNewOrchestration
1335
+ ? (awsInstance?.progressComplete ?? 0)
1336
+ : (statusPercentSignallingServer ?? 0) / 100;
1324
1337
  // +1% bump once WS URL is known (visual step for WS handshake)
1325
1338
  const splitPoint = awsInstance?.wsUrl
1326
1339
  ? SIGNALLING_MAX + 0.01
@@ -1664,6 +1677,10 @@ class RegionsPingService {
1664
1677
  * entry is well-formed and still within {@link REGION_PING_CACHE_TTL_MS}.
1665
1678
  * All access is guarded — `localStorage` can throw (private mode, disabled
1666
1679
  * storage) or be absent (non-browser).
1680
+ *
1681
+ * Public so adopted connections (which skip `getFastest`) can replay the
1682
+ * region-resolving telemetry phase from the same cached winner — see
1683
+ * `UnrealEffects.regionTelemetryOnAdopt$`.
1667
1684
  */
1668
1685
  readCachedWinner() {
1669
1686
  try {
@@ -2573,7 +2590,13 @@ class VideoService extends SubService {
2573
2590
  this.audio = null;
2574
2591
  this.container = null;
2575
2592
  this.latencyTestTimings = new LatencyTimings();
2576
- this.videoTrack$ = new Subject();
2593
+ // BehaviorSubject (not Subject) so a late subscriber to `videoStats$` still
2594
+ // gets the current track. Adoption (`WebRtcPlayerService.adopt`) calls
2595
+ // `attachVideoStream` synchronously/early — before the resize and app-init
2596
+ // (`initAppByDataChannelReady$`) subscribers attach — and a plain Subject
2597
+ // would drop that emission, so the stats interval (and `setViewportReady`,
2598
+ // gated on `framesPerSecond`) would never start.
2599
+ this.videoTrack$ = new BehaviorSubject(null);
2577
2600
  this.VideoEncoderQP = 0;
2578
2601
  this.aggregatedStats = {};
2579
2602
  this.kalmanFilter1D = new KalmanFilter1D(FilterModel.initialBitrateEstimate, FilterModel.initialErrorCovariance, FilterModel.processNoise, FilterModel.measurementNoise);
@@ -3579,6 +3602,70 @@ class StreamAdoptionService {
3579
3602
  this.signalling = inject(SignallingService);
3580
3603
  this.webRtc = inject(WebRtcPlayerService);
3581
3604
  }
3605
+ /**
3606
+ * Decide adoption, WAITING for an in-flight prefetch instead of racing it.
3607
+ *
3608
+ * - nothing parked → emit `false` at once (caller connects normally).
3609
+ * - already settled (`ready`/`failed`/`adopted`) → adopt-if-ready at once.
3610
+ * - still `connecting` → wait for the script's `onSettled` callback and
3611
+ * adopt-if-ready the instant it fires (event-driven, zero polling latency).
3612
+ *
3613
+ * Deliberately NO time budget: for DIRECT orchestration the prefetch holds
3614
+ * the socket open while the orchestrator provisions a machine, which can take
3615
+ * minutes (150–200s+). A fixed deadline would make Angular give up and open a
3616
+ * second connection mid-provisioning — exactly the race/orphan this avoids.
3617
+ * The wait is bounded by liveness instead: the script flips the parked state
3618
+ * to `failed` on a dead socket (`ws.onclose`/`onerror`), and the caller wraps
3619
+ * this in `takeUntil(abortEstablishingConnection)`, so a route change / teardown
3620
+ * cancels it. Emits exactly once. Cancelling runs no adoption — which is what
3621
+ * stops a fast route-switch from starting overlapping connections.
3622
+ *
3623
+ * Wrapped in `defer` so the `tryAdopt()` side effect (which dispatches
3624
+ * `streamAdopted`) runs at SUBSCRIPTION time, not when the observable is
3625
+ * built. The effect prepends `startWith(setEstablishingConnection(true))`,
3626
+ * which starts the telemetry session and subscribes its phase catalog; that
3627
+ * must happen BEFORE `streamAdopted` → `regionTelemetryOnAdopt$` replays
3628
+ * `regionResolved`, or the region phase fires into a session that doesn't
3629
+ * exist yet and is lost. `defer` guarantees that ordering.
3630
+ */
3631
+ awaitAndAdopt$() {
3632
+ return defer(() => {
3633
+ const parked = window.__stream;
3634
+ if (!parked) {
3635
+ return of(false);
3636
+ }
3637
+ if (parked.state !== 'connecting') {
3638
+ return of(this.tryAdopt());
3639
+ }
3640
+ Logger.info('[Adoption] Prefetch in progress — waiting before connecting');
3641
+ return this.awaitSettled$(parked);
3642
+ });
3643
+ }
3644
+ /**
3645
+ * Event-driven wait: the script fires `onSettled` the instant it parks
3646
+ * `ready`/`failed`, so we adopt with zero polling lag. Emits exactly once.
3647
+ *
3648
+ * No timer/budget by design: an indefinitely `connecting` stream is valid
3649
+ * (DIRECT orchestration holds the socket open for minutes while it
3650
+ * provisions), and every terminal state — including a dead socket — flips to
3651
+ * `ready`/`failed` and fires the hook. Teardown (route change → unsubscribe)
3652
+ * sets `done`, so a late callback after cancel can't trigger a stray adoption.
3653
+ */
3654
+ awaitSettled$(parked) {
3655
+ return new Observable((subscriber) => {
3656
+ let done = false;
3657
+ parked.onSettled(() => {
3658
+ if (done)
3659
+ return;
3660
+ done = true;
3661
+ subscriber.next(this.tryAdopt());
3662
+ subscriber.complete();
3663
+ });
3664
+ return () => {
3665
+ done = true;
3666
+ };
3667
+ });
3668
+ }
3582
3669
  /**
3583
3670
  * @returns `true` if a ready parked stream was adopted (caller must NOT
3584
3671
  * start a fresh connection), `false` otherwise.
@@ -3598,14 +3685,19 @@ class StreamAdoptionService {
3598
3685
  return false;
3599
3686
  }
3600
3687
  Logger.info('[Adoption] Adopting prefetched stream', parked.timings);
3601
- // Stop the script's watchdog and mark the handoff complete.
3688
+ // Clear the script's timers and mark the handoff complete.
3602
3689
  parked.adopt();
3603
3690
  // Flag cirrus connected (so a later `initSignalling` won't open a 2nd
3604
- // connection) and record the adopted machine. NOTE: clientId/viewId are
3605
- // left untouched — the host app (metabox) already set them authoritatively
3606
- // from its own data before `initSignalling`; the script's ids are only used
3607
- // internally to establish the prefetched connection.
3608
- this.store.dispatch(streamAdopted({ machineId: parked.machineId ?? null }));
3691
+ // connection), record the adopted machine, and hydrate the streamer info
3692
+ // (`ssData`) the prefetch captured from the `ssInfo` message. NOTE:
3693
+ // clientId/viewId are left untouched — the host app (metabox) already set
3694
+ // them authoritatively from its own data before `initSignalling`; the
3695
+ // script's ids are only used internally to establish the connection.
3696
+ this.store.dispatch(streamAdopted({
3697
+ machineId: parked.machineId ?? null,
3698
+ ssData: parked.ssData ?? null,
3699
+ timings: parked.timings ?? null,
3700
+ }));
3609
3701
  // Hand the live socket over without re-sending `requestStream`, reusing the
3610
3702
  // script's correlationId (the one `requestStream` was already sent with).
3611
3703
  this.signalling.adoptSocket(parked.ws, parked.ids.correlationId);
@@ -4148,12 +4240,19 @@ class UnrealEffects {
4148
4240
  }), map(([, matchMakerUrls]) => [...matchMakerUrls].filter(Truthy)), filter((urls) => urls.length > 0),
4149
4241
  // Ensure a single in-flight request per attempt.
4150
4242
  exhaustMap((urls) =>
4151
- // Adopt a prefetched `window.__stream` if one is ready; otherwise
4152
- // fall back to opening a fresh signalling connection.
4153
- this.streamAdoptionService.tryAdopt()
4154
- ? EMPTY
4155
- : this.signallingService.connectToSignaling(urls)));
4156
- }, { dispatch: false });
4243
+ // If a prefetched `window.__stream` is ready — or still connecting —
4244
+ // adopt it (waiting for it to finish) instead of racing a second
4245
+ // connection; otherwise fall back to a fresh signalling connection.
4246
+ // `takeUntil(abort)` cancels the wait on teardown / route change so a
4247
+ // fast switch can't leave overlapping connections.
4248
+ this.streamAdoptionService.awaitAndAdopt$().pipe(switchMap((adopted) => adopted ? EMPTY : this.signallingService.connectToSignaling(urls)), takeUntil(this.actions$.pipe(ofType(abortEstablishingConnection))),
4249
+ // The adopt/connect stream is side-effect-only (its values aren't
4250
+ // actions) — swallow them, and emit `setEstablishingConnection`
4251
+ // first so the reconnect/resume popup stays hidden during the
4252
+ // (possibly deferred) prefetch-adoption wait. Adoption otherwise
4253
+ // defers `connectToSignaling`, which is what normally sets this.
4254
+ ignoreElements(), startWith(setEstablishingConnection({ value: true })))));
4255
+ });
4157
4256
  }
4158
4257
  canPeerReconnect() {
4159
4258
  return RECONNECT_CONFIG.enabled && this.signallingService.isConnected();
@@ -5455,6 +5554,19 @@ const QP_RED_THRESHOLD = 35;
5455
5554
  const FPS_LOW_RATIO = 0.7; // p50 fps below 70% of target
5456
5555
  const UNLIMITED_REFERENCE_FPS = 60;
5457
5556
  const SAMPLE_WINDOW_MS = 5000;
5557
+ /**
5558
+ * Catalog phase ids the prefetch establishes invisibly. For an adopted
5559
+ * connection their live closers are suppressed and the phases are emitted by
5560
+ * {@link StreamStatusTelemetryService2.emitAdoptedConnection} instead, timed
5561
+ * from the prefetch's own marks and anchored at the moment Angular reached the
5562
+ * connect call (session start) — so the waterfall shows the time the prefetch
5563
+ * actually SAVED, not the prefetch's page-load-parallel raw timeline.
5564
+ */
5565
+ const PREFETCHED_PHASE_IDS = new Set([
5566
+ 'region-resolving',
5567
+ 'signaling-connection',
5568
+ 'webrtc-negotiation',
5569
+ ]);
5458
5570
  // Persistent per-browser identifier — survives page reloads. Used to group
5459
5571
  // multiple sessions of the same anonymous user for funnel / retention analysis.
5460
5572
  const USER_ID_STORAGE_KEY = 'source3d.streamTelemetry.userId';
@@ -5485,6 +5597,17 @@ class StreamStatusTelemetryService2 {
5485
5597
  this.disconnectReason$ = this.store.selectSignal(unrealFeature.selectDisconnectReason);
5486
5598
  this.errorMessage$ = this.store.selectSignal(unrealFeature.selectErrorMessage);
5487
5599
  this.viewPortReady$ = this.store.selectSignal(unrealFeature.selectViewportReady);
5600
+ /**
5601
+ * Synchronous adoption flag. The `streamAdopted` reducer sets it BEFORE any
5602
+ * action subscriber runs, so the connection-phase closers (which fire inside
5603
+ * the same dispatch, e.g. `ssinfo-received` off the `ssData` state change)
5604
+ * can read it and defer to the prefetch-timed attribution emitted by
5605
+ * {@link emitAdoptedConnection} instead of recording the live wall-clock.
5606
+ */
5607
+ this.adopted$ = this.store.selectSignal(unrealFeature.selectAdopted);
5608
+ /** Streamer identity for the adopted signaling phase params. */
5609
+ this.ssData$ = this.store.selectSignal(unrealFeature.selectSsData);
5610
+ this.awsInstance$ = this.store.selectSignal(unrealFeature.selectAwsInstance);
5488
5611
  this.session = null;
5489
5612
  /**
5490
5613
  * Whether a session has already started during the current page lifetime.
@@ -5562,7 +5685,10 @@ class StreamStatusTelemetryService2 {
5562
5685
  // CLOSER — the `regionResolved` action carries the raw ping
5563
5686
  // samples (one per probe) from `RegionsPingService`; the
5564
5687
  // catalog flattens them to a comma-separated string since
5565
- // `toPhaseParams` only accepts primitives.
5688
+ // `toPhaseParams` only accepts primitives. Adopted connections never
5689
+ // run `getRegion`, but `UnrealEffects.regionTelemetryOnAdopt$` replays
5690
+ // the region waterfall (start → end → resolved) from the cached winner,
5691
+ // so this phase still appears with its ping samples.
5566
5692
  {
5567
5693
  id: 'region-resolved',
5568
5694
  event: this.actions$.pipe(ofType(regionResolved), take(1), map(({ region, pingsMs }) => ({
@@ -5874,6 +6000,13 @@ class StreamStatusTelemetryService2 {
5874
6000
  if (!this.session)
5875
6001
  return;
5876
6002
  if (isCloser) {
6003
+ // Adopted connections receive the region / signaling / webrtc
6004
+ // phases from `emitAdoptedConnection` (timed from the prefetch's
6005
+ // own marks). Skip the live closer for those, so the whole
6006
+ // pre-adoption wait isn't dumped onto whichever connection closer
6007
+ // happens to fire first at the adoption instant.
6008
+ if (this.adopted$() && PREFETCHED_PHASE_IDS.has(bucket.id))
6009
+ return;
5877
6010
  this.emitPhaseEnded(bucket, bucketIdx, value);
5878
6011
  return;
5879
6012
  }
@@ -5884,6 +6017,12 @@ class StreamStatusTelemetryService2 {
5884
6017
  }));
5885
6018
  });
5886
6019
  });
6020
+ // Adopted connection: emit the region / signaling / webrtc phases timed
6021
+ // from the prefetch marks, anchored at session start (see method doc). The
6022
+ // live closers for these phases are suppressed (PREFETCHED_PHASE_IDS).
6023
+ sub.add(this.actions$
6024
+ .pipe(ofType(streamAdopted))
6025
+ .subscribe(({ timings }) => this.emitAdoptedConnection(timings)));
5887
6026
  const bootstrapInfo = this.collectBootstrapInfo();
5888
6027
  this.emitLifecycle('stream.session_started', {
5889
6028
  sessionId: id,
@@ -6001,6 +6140,127 @@ class StreamStatusTelemetryService2 {
6001
6140
  overlays,
6002
6141
  });
6003
6142
  }
6143
+ /**
6144
+ * Emit the region / signaling / webrtc phases for an ADOPTED connection.
6145
+ *
6146
+ * The prefetch script established the connection in PARALLEL with page load,
6147
+ * so the phases are positioned at their real (anchor-relative) spans — shifted
6148
+ * LEFT to overlap the HTML-loading / route-idle rows — rather than sequentially
6149
+ * after them. That's the whole point of the prefetch: the handshake already
6150
+ * happened while the page was loading. Each phase ships:
6151
+ * - `duration` — the real sub-step time the prefetch spent.
6152
+ * - `spanStartMs` — anchor-relative start, so the viewer left-shifts the bar.
6153
+ * - `adoptionAtMs` — where Angular took over (the next phase, Unreal
6154
+ * communication, resumes here on the sequential timeline).
6155
+ * - savings params: `prefetchConnectMs` (full handshake), `prefetchSavedMs`
6156
+ * (done before Angular was ready), `adoptionWaitMs` (real connect→adopted
6157
+ * wait), `adoptionOverheadMs` (prefetch-ready → adopted gap).
6158
+ */
6159
+ emitAdoptedConnection(timings) {
6160
+ const s = this.session;
6161
+ if (!s)
6162
+ return;
6163
+ const t = timings ?? {};
6164
+ const now = performance.now();
6165
+ // Everything in anchor-relative ms — the same scale the viewer renders on
6166
+ // (and the prefetch marks share the page `performance` clock).
6167
+ const toMs = (perf) => Math.round(perf - s.anchorPerf);
6168
+ const startMs = toMs(t['prefetch_start'] ?? s.startedAtPerf);
6169
+ const wsOpenMs = Math.max(startMs, toMs(t['ws_open'] ?? t['prefetch_start'] ?? s.startedAtPerf));
6170
+ const readyMs = Math.max(wsOpenMs, toMs(t['ready'] ?? t['dc_open'] ?? t['ws_open'] ?? s.startedAtPerf));
6171
+ const adoptionAtMs = toMs(now);
6172
+ const sessionStartMs = toMs(s.startedAtPerf);
6173
+ const sigDur = wsOpenMs - startMs;
6174
+ const webDur = readyMs - wsOpenMs;
6175
+ const connectMs = readyMs - startMs;
6176
+ // Savings, anchored at the moment Angular reached the connect call.
6177
+ const savings = {
6178
+ prefetched: true,
6179
+ prefetchConnectMs: connectMs,
6180
+ // How much of the handshake finished before Angular was ready (the win).
6181
+ prefetchSavedMs: Math.max(0, Math.min(connectMs, sessionStartMs - startMs)),
6182
+ // The real wait Angular experienced (connect call → adopted).
6183
+ adoptionWaitMs: Math.max(0, adoptionAtMs - sessionStartMs),
6184
+ // Gap between the prefetch being ready and Angular adopting it.
6185
+ adoptionOverheadMs: Math.max(0, adoptionAtMs - readyMs),
6186
+ };
6187
+ // Region (phaseNo 1) — served from the ping cache during prefetch (~0),
6188
+ // positioned at the prefetch start; ship the samples.
6189
+ const region = this.readRegionCache();
6190
+ this.emitAdoptedPhase(0, startMs, 0, adoptionAtMs, {
6191
+ region: region?.region_code ?? '',
6192
+ pingsMs: region?.result.length ? `${region.result.join(', ')} ms` : null,
6193
+ });
6194
+ // Signaling (phaseNo 2) — [prefetch_start → ws_open]; streamer identity +
6195
+ // savings; ws-connected marker sits at the phase's own end.
6196
+ this.emitAdoptedPhase(1, startMs, sigDur, adoptionAtMs, {
6197
+ ...this.adoptedSignalingParams(),
6198
+ ...savings,
6199
+ prefetchSignalingMs: sigDur,
6200
+ }, [{ id: 'signaling-ws-connected', time: wsOpenMs }]);
6201
+ // WebRTC negotiation (phaseNo 3) — [ws_open → ready].
6202
+ this.emitAdoptedPhase(2, wsOpenMs, webDur, adoptionAtMs, {
6203
+ iceCandidateCount: 0,
6204
+ prefetchWebrtcMs: webDur,
6205
+ });
6206
+ // Unreal communication chains from the adoption moment (real wall-clock).
6207
+ s.lastMilestonePerf = now;
6208
+ }
6209
+ /**
6210
+ * Emit one adopted phase positioned at its real `spanStartMs` (left-shifted
6211
+ * to overlap page load) with its real prefetch `duration`. `prefetched`,
6212
+ * `spanStartMs` and `adoptionAtMs` ride as top-level wire fields the viewer
6213
+ * uses to lay the bar out; the rest become display params.
6214
+ */
6215
+ emitAdoptedPhase(bucketIdx, spanStartMs, durationMs, adoptionAtMs, extras, milestones = []) {
6216
+ const s = this.session;
6217
+ if (!s)
6218
+ return;
6219
+ const bucket = this.phaseCatalog[bucketIdx];
6220
+ this.track('stream.phase_ended', {
6221
+ sessionId: s.id,
6222
+ phaseNo: bucketIdx + 1,
6223
+ id: bucket.id,
6224
+ color: bucket.color,
6225
+ duration: durationMs,
6226
+ prefetched: true,
6227
+ spanStartMs,
6228
+ adoptionAtMs,
6229
+ milestones,
6230
+ params: toPhaseParams({ ...extras, prefetched: true }),
6231
+ overlays: [],
6232
+ });
6233
+ }
6234
+ /** Streamer identity params for the adopted signaling phase — mirrors the
6235
+ * `ssinfo-received` closer's shape (client / instanceName / …ssData). */
6236
+ adoptedSignalingParams() {
6237
+ const aws = this.awsInstance$();
6238
+ const ssData = this.ssData$();
6239
+ return {
6240
+ client: aws?.wsUrl ?? null,
6241
+ instanceName: aws?.instanceName ?? null,
6242
+ ...(ssData ?? {}),
6243
+ };
6244
+ }
6245
+ /** Read the region-ping cache the prefetch reused (localStorage), so the
6246
+ * adopted region phase can still report the winning region + samples. */
6247
+ readRegionCache() {
6248
+ try {
6249
+ const raw = localStorage.getItem(REGION_PING_CACHE_KEY);
6250
+ if (!raw)
6251
+ return null;
6252
+ const parsed = JSON.parse(raw);
6253
+ return {
6254
+ region_code: typeof parsed.region_code === 'string' ? parsed.region_code : '',
6255
+ result: Array.isArray(parsed.result)
6256
+ ? parsed.result.filter((n) => typeof n === 'number')
6257
+ : [],
6258
+ };
6259
+ }
6260
+ catch {
6261
+ return null;
6262
+ }
6263
+ }
6004
6264
  handleWindow(samples) {
6005
6265
  if (!this.session)
6006
6266
  return;