@3dsource/angular-unreal-module 0.0.146-dev.1 → 0.0.146-dev.11

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.
@@ -4,7 +4,7 @@ import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-
4
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
- import { filter, map as map$1, distinctUntilChanged, takeWhile, takeUntil, catchError, tap, timeout, finalize, first, switchMap as switchMap$1, last, withLatestFrom, exhaustMap, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
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';
8
8
  import { Actions, ofType, createEffect, provideEffects } from '@ngrx/effects';
9
9
  import { concatLatestFrom } from '@ngrx/operators';
10
10
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
@@ -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 }) => {
@@ -647,6 +652,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
647
652
  return {
648
653
  ...state,
649
654
  cirrusConnected: true,
655
+ adopted: true,
650
656
  ssData: ssData ?? state.ssData,
651
657
  awsInstance: {
652
658
  ...state.awsInstance,
@@ -677,6 +683,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
677
683
  return {
678
684
  ...state,
679
685
  cirrusConnected: false,
686
+ adopted: false,
680
687
  dataChannelConnected: false,
681
688
  viewportReady: false,
682
689
  isVideoPlaying: false,
@@ -1670,6 +1677,10 @@ class RegionsPingService {
1670
1677
  * entry is well-formed and still within {@link REGION_PING_CACHE_TTL_MS}.
1671
1678
  * All access is guarded — `localStorage` can throw (private mode, disabled
1672
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$`.
1673
1684
  */
1674
1685
  readCachedWinner() {
1675
1686
  try {
@@ -3578,8 +3589,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
3578
3589
  args: [{ providedIn: 'root' }]
3579
3590
  }], ctorParameters: () => [] });
3580
3591
 
3581
- // How often to re-check `window.__stream.state` while a prefetch is connecting.
3582
- const ADOPT_POLL_MS = 100;
3583
3592
  /**
3584
3593
  * Adopts a WebRTC connection prefetched by the `stream-prefetch.js` head-script
3585
3594
  * and parked on `window.__stream`, instead of running `connectToSignaling` from
@@ -3598,26 +3607,64 @@ class StreamAdoptionService {
3598
3607
  *
3599
3608
  * - nothing parked → emit `false` at once (caller connects normally).
3600
3609
  * - already settled (`ready`/`failed`/`adopted`) → adopt-if-ready at once.
3601
- * - still `connecting` → poll until it settles, then adopt-if-ready. Bounded
3602
- * by `timeoutMs` so a stuck prefetch can't block the fallback forever.
3610
+ * - still `connecting` → wait for the script's `onSettled` callback and
3611
+ * adopt-if-ready the instant it fires (event-driven, zero polling latency).
3603
3612
  *
3604
- * Emits exactly once. Cancel by unsubscribing (the effect does this on
3605
- * teardown via `takeUntil`) — no adoption runs after cancellation, which is
3606
- * what stops a fast route-switch from starting overlapping connections.
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.
3607
3630
  */
3608
- awaitAndAdopt$(timeoutMs) {
3609
- const parked = window.__stream;
3610
- if (!parked) {
3611
- return of(false);
3612
- }
3613
- if (parked.state !== 'connecting') {
3614
- return of(this.tryAdopt());
3615
- }
3616
- Logger.info('[Adoption] Prefetch in progress — waiting before connecting');
3617
- return timer(0, ADOPT_POLL_MS).pipe(map$1(() => window.__stream?.state ?? 'failed'),
3618
- // Keep polling while still connecting AND within budget; the `true`
3619
- // (inclusive) emits the settling value (or the last one at the deadline).
3620
- takeWhile((state, i) => state === 'connecting' && i * ADOPT_POLL_MS < timeoutMs, true), last(), map$1(() => this.tryAdopt()));
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
+ });
3621
3668
  }
3622
3669
  /**
3623
3670
  * @returns `true` if a ready parked stream was adopted (caller must NOT
@@ -3638,7 +3685,7 @@ class StreamAdoptionService {
3638
3685
  return false;
3639
3686
  }
3640
3687
  Logger.info('[Adoption] Adopting prefetched stream', parked.timings);
3641
- // Stop the script's watchdog and mark the handoff complete.
3688
+ // Clear the script's timers and mark the handoff complete.
3642
3689
  parked.adopt();
3643
3690
  // Flag cirrus connected (so a later `initSignalling` won't open a 2nd
3644
3691
  // connection), record the adopted machine, and hydrate the streamer info
@@ -3649,6 +3696,7 @@ class StreamAdoptionService {
3649
3696
  this.store.dispatch(streamAdopted({
3650
3697
  machineId: parked.machineId ?? null,
3651
3698
  ssData: parked.ssData ?? null,
3699
+ timings: parked.timings ?? null,
3652
3700
  }));
3653
3701
  // Hand the live socket over without re-sending `requestStream`, reusing the
3654
3702
  // script's correlationId (the one `requestStream` was already sent with).
@@ -4197,9 +4245,7 @@ class UnrealEffects {
4197
4245
  // connection; otherwise fall back to a fresh signalling connection.
4198
4246
  // `takeUntil(abort)` cancels the wait on teardown / route change so a
4199
4247
  // fast switch can't leave overlapping connections.
4200
- this.streamAdoptionService
4201
- .awaitAndAdopt$(this.dataChannelConnectionTimeout)
4202
- .pipe(switchMap((adopted) => adopted ? EMPTY : this.signallingService.connectToSignaling(urls)), takeUntil(this.actions$.pipe(ofType(abortEstablishingConnection))),
4248
+ this.streamAdoptionService.awaitAndAdopt$().pipe(switchMap((adopted) => adopted ? EMPTY : this.signallingService.connectToSignaling(urls)), takeUntil(this.actions$.pipe(ofType(abortEstablishingConnection))),
4203
4249
  // The adopt/connect stream is side-effect-only (its values aren't
4204
4250
  // actions) — swallow them, and emit `setEstablishingConnection`
4205
4251
  // first so the reconnect/resume popup stays hidden during the
@@ -5508,6 +5554,19 @@ const QP_RED_THRESHOLD = 35;
5508
5554
  const FPS_LOW_RATIO = 0.7; // p50 fps below 70% of target
5509
5555
  const UNLIMITED_REFERENCE_FPS = 60;
5510
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
+ ]);
5511
5570
  // Persistent per-browser identifier — survives page reloads. Used to group
5512
5571
  // multiple sessions of the same anonymous user for funnel / retention analysis.
5513
5572
  const USER_ID_STORAGE_KEY = 'source3d.streamTelemetry.userId';
@@ -5538,6 +5597,17 @@ class StreamStatusTelemetryService2 {
5538
5597
  this.disconnectReason$ = this.store.selectSignal(unrealFeature.selectDisconnectReason);
5539
5598
  this.errorMessage$ = this.store.selectSignal(unrealFeature.selectErrorMessage);
5540
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);
5541
5611
  this.session = null;
5542
5612
  /**
5543
5613
  * Whether a session has already started during the current page lifetime.
@@ -5615,7 +5685,10 @@ class StreamStatusTelemetryService2 {
5615
5685
  // CLOSER — the `regionResolved` action carries the raw ping
5616
5686
  // samples (one per probe) from `RegionsPingService`; the
5617
5687
  // catalog flattens them to a comma-separated string since
5618
- // `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.
5619
5692
  {
5620
5693
  id: 'region-resolved',
5621
5694
  event: this.actions$.pipe(ofType(regionResolved), take(1), map(({ region, pingsMs }) => ({
@@ -5927,6 +6000,13 @@ class StreamStatusTelemetryService2 {
5927
6000
  if (!this.session)
5928
6001
  return;
5929
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;
5930
6010
  this.emitPhaseEnded(bucket, bucketIdx, value);
5931
6011
  return;
5932
6012
  }
@@ -5937,6 +6017,12 @@ class StreamStatusTelemetryService2 {
5937
6017
  }));
5938
6018
  });
5939
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)));
5940
6026
  const bootstrapInfo = this.collectBootstrapInfo();
5941
6027
  this.emitLifecycle('stream.session_started', {
5942
6028
  sessionId: id,
@@ -6054,6 +6140,154 @@ class StreamStatusTelemetryService2 {
6054
6140
  overlays,
6055
6141
  });
6056
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
+ // Main-thread blocking from the OPTIONAL `perf-longtask.js` head-script. If
6177
+ // that script isn't on the page `window.__streamPerf` is undefined and
6178
+ // these stay 0 — nothing breaks. Tasks carry page-`performance.now()` start
6179
+ // times, so window them against the raw (pre-`toMs`) ready/adoption marks:
6180
+ // bootBlockingMs — total over [0, adoption]: overall JS cost to boot.
6181
+ // adoptBlockingMs — within [ready, adoption]: how much of the
6182
+ // "awaiting Angular code" gap was the thread actually
6183
+ // blocked by JS (vs idle waiting). ≈gap ⇒ JS-bound;
6184
+ // ≈0 ⇒ the app was just slow to reach the adopt call.
6185
+ const readyPerf = t['ready'] ?? t['dc_open'] ?? t['ws_open'] ?? s.startedAtPerf;
6186
+ const tasks = window.__streamPerf?.longTasks ?? [];
6187
+ let bootBlockingMs = 0;
6188
+ let adoptBlockingMs = 0;
6189
+ let maxLongTaskMs = 0;
6190
+ for (const lt of tasks) {
6191
+ if (lt.start > now)
6192
+ continue; // ignore tasks after adoption
6193
+ bootBlockingMs += lt.dur;
6194
+ if (lt.dur > maxLongTaskMs)
6195
+ maxLongTaskMs = lt.dur;
6196
+ if (lt.start >= readyPerf)
6197
+ adoptBlockingMs += lt.dur;
6198
+ }
6199
+ // Savings, anchored at the moment Angular reached the connect call.
6200
+ const savings = {
6201
+ prefetched: true,
6202
+ prefetchConnectMs: connectMs,
6203
+ // How much of the handshake finished before Angular was ready (the win).
6204
+ prefetchSavedMs: Math.max(0, Math.min(connectMs, sessionStartMs - startMs)),
6205
+ // The real wait Angular experienced (connect call → adopted).
6206
+ adoptionWaitMs: Math.max(0, adoptionAtMs - sessionStartMs),
6207
+ // Gap between the prefetch being ready and Angular adopting it.
6208
+ adoptionOverheadMs: Math.max(0, adoptionAtMs - readyMs),
6209
+ // Main-thread blocking (longtask) — diagnoses whether the gap is JS-bound.
6210
+ bootBlockingMs: Math.round(bootBlockingMs),
6211
+ adoptBlockingMs: Math.round(adoptBlockingMs),
6212
+ maxLongTaskMs: Math.round(maxLongTaskMs),
6213
+ };
6214
+ // Region (phaseNo 1) — served from the ping cache during prefetch (~0),
6215
+ // positioned at the prefetch start; ship the samples.
6216
+ const region = this.readRegionCache();
6217
+ this.emitAdoptedPhase(0, startMs, 0, adoptionAtMs, {
6218
+ region: region?.region_code ?? '',
6219
+ pingsMs: region?.result.length ? `${region.result.join(', ')} ms` : null,
6220
+ });
6221
+ // Signaling (phaseNo 2) — [prefetch_start → ws_open]; streamer identity +
6222
+ // savings; ws-connected marker sits at the phase's own end.
6223
+ this.emitAdoptedPhase(1, startMs, sigDur, adoptionAtMs, {
6224
+ ...this.adoptedSignalingParams(),
6225
+ ...savings,
6226
+ prefetchSignalingMs: sigDur,
6227
+ }, [{ id: 'signaling-ws-connected', time: wsOpenMs }]);
6228
+ // WebRTC negotiation (phaseNo 3) — [ws_open → ready].
6229
+ this.emitAdoptedPhase(2, wsOpenMs, webDur, adoptionAtMs, {
6230
+ iceCandidateCount: 0,
6231
+ prefetchWebrtcMs: webDur,
6232
+ });
6233
+ // Unreal communication chains from the adoption moment (real wall-clock).
6234
+ s.lastMilestonePerf = now;
6235
+ }
6236
+ /**
6237
+ * Emit one adopted phase positioned at its real `spanStartMs` (left-shifted
6238
+ * to overlap page load) with its real prefetch `duration`. `prefetched`,
6239
+ * `spanStartMs` and `adoptionAtMs` ride as top-level wire fields the viewer
6240
+ * uses to lay the bar out; the rest become display params.
6241
+ */
6242
+ emitAdoptedPhase(bucketIdx, spanStartMs, durationMs, adoptionAtMs, extras, milestones = []) {
6243
+ const s = this.session;
6244
+ if (!s)
6245
+ return;
6246
+ const bucket = this.phaseCatalog[bucketIdx];
6247
+ this.track('stream.phase_ended', {
6248
+ sessionId: s.id,
6249
+ phaseNo: bucketIdx + 1,
6250
+ id: bucket.id,
6251
+ color: bucket.color,
6252
+ duration: durationMs,
6253
+ prefetched: true,
6254
+ spanStartMs,
6255
+ adoptionAtMs,
6256
+ milestones,
6257
+ params: toPhaseParams({ ...extras, prefetched: true }),
6258
+ overlays: [],
6259
+ });
6260
+ }
6261
+ /** Streamer identity params for the adopted signaling phase — mirrors the
6262
+ * `ssinfo-received` closer's shape (client / instanceName / …ssData). */
6263
+ adoptedSignalingParams() {
6264
+ const aws = this.awsInstance$();
6265
+ const ssData = this.ssData$();
6266
+ return {
6267
+ client: aws?.wsUrl ?? null,
6268
+ instanceName: aws?.instanceName ?? null,
6269
+ ...(ssData ?? {}),
6270
+ };
6271
+ }
6272
+ /** Read the region-ping cache the prefetch reused (localStorage), so the
6273
+ * adopted region phase can still report the winning region + samples. */
6274
+ readRegionCache() {
6275
+ try {
6276
+ const raw = localStorage.getItem(REGION_PING_CACHE_KEY);
6277
+ if (!raw)
6278
+ return null;
6279
+ const parsed = JSON.parse(raw);
6280
+ return {
6281
+ region_code: typeof parsed.region_code === 'string' ? parsed.region_code : '',
6282
+ result: Array.isArray(parsed.result)
6283
+ ? parsed.result.filter((n) => typeof n === 'number')
6284
+ : [],
6285
+ };
6286
+ }
6287
+ catch {
6288
+ return null;
6289
+ }
6290
+ }
6057
6291
  handleWindow(samples) {
6058
6292
  if (!this.session)
6059
6293
  return;