@3dsource/angular-unreal-module 0.0.146-dev.5 → 0.0.146-dev.7

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.
@@ -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,
@@ -3613,20 +3620,30 @@ class StreamAdoptionService {
3613
3620
  * this in `takeUntil(abortEstablishingConnection)`, so a route change / teardown
3614
3621
  * cancels it. Emits exactly once. Cancelling runs no adoption — which is what
3615
3622
  * stops a fast route-switch from starting overlapping connections.
3623
+ *
3624
+ * Wrapped in `defer` so the `tryAdopt()` side effect (which dispatches
3625
+ * `streamAdopted`) runs at SUBSCRIPTION time, not when the observable is
3626
+ * built. The effect prepends `startWith(setEstablishingConnection(true))`,
3627
+ * which starts the telemetry session and subscribes its phase catalog; that
3628
+ * must happen BEFORE `streamAdopted` → `regionTelemetryOnAdopt$` replays
3629
+ * `regionResolved`, or the region phase fires into a session that doesn't
3630
+ * exist yet and is lost. `defer` guarantees that ordering.
3616
3631
  */
3617
3632
  awaitAndAdopt$() {
3618
- const parked = window.__stream;
3619
- if (!parked) {
3620
- return of(false);
3621
- }
3622
- if (parked.state !== 'connecting') {
3623
- return of(this.tryAdopt());
3624
- }
3625
- Logger.info('[Adoption] Prefetch in progress — waiting before connecting');
3626
- return timer(0, ADOPT_POLL_MS).pipe(map$1(() => window.__stream?.state ?? 'failed'),
3627
- // Keep polling while still connecting; the `true` (inclusive) emits the
3628
- // settling value (`ready`/`failed`) that ends the wait.
3629
- takeWhile((state) => state === 'connecting', true), last(), map$1(() => this.tryAdopt()));
3633
+ return defer(() => {
3634
+ const parked = window.__stream;
3635
+ if (!parked) {
3636
+ return of(false);
3637
+ }
3638
+ if (parked.state !== 'connecting') {
3639
+ return of(this.tryAdopt());
3640
+ }
3641
+ Logger.info('[Adoption] Prefetch in progress — waiting before connecting');
3642
+ return timer(0, ADOPT_POLL_MS).pipe(map$1(() => window.__stream?.state ?? 'failed'),
3643
+ // Keep polling while still connecting; the `true` (inclusive) emits the
3644
+ // settling value (`ready`/`failed`) that ends the wait.
3645
+ takeWhile((state) => state === 'connecting', true), last(), map$1(() => this.tryAdopt()));
3646
+ });
3630
3647
  }
3631
3648
  /**
3632
3649
  * @returns `true` if a ready parked stream was adopted (caller must NOT
@@ -3647,7 +3664,7 @@ class StreamAdoptionService {
3647
3664
  return false;
3648
3665
  }
3649
3666
  Logger.info('[Adoption] Adopting prefetched stream', parked.timings);
3650
- // Stop the script's watchdog and mark the handoff complete.
3667
+ // Clear the script's timers and mark the handoff complete.
3651
3668
  parked.adopt();
3652
3669
  // Flag cirrus connected (so a later `initSignalling` won't open a 2nd
3653
3670
  // connection), record the adopted machine, and hydrate the streamer info
@@ -3658,6 +3675,7 @@ class StreamAdoptionService {
3658
3675
  this.store.dispatch(streamAdopted({
3659
3676
  machineId: parked.machineId ?? null,
3660
3677
  ssData: parked.ssData ?? null,
3678
+ timings: parked.timings ?? null,
3661
3679
  }));
3662
3680
  // Hand the live socket over without re-sending `requestStream`, reusing the
3663
3681
  // script's correlationId (the one `requestStream` was already sent with).
@@ -4214,24 +4232,6 @@ class UnrealEffects {
4214
4232
  // defers `connectToSignaling`, which is what normally sets this.
4215
4233
  ignoreElements(), startWith(setEstablishingConnection({ value: true })))));
4216
4234
  });
4217
- // Adoption skips `connectToSignaling → getRegion`, so the region-resolving
4218
- // telemetry phase never fires. Replay the region waterfall (start → end →
4219
- // resolved) from the cached winner the prefetch reused — a near-zero-length
4220
- // phase that still carries the ping samples. No live ping sweep; if there is
4221
- // no cache the resolved event just carries empty values.
4222
- this.regionTelemetryOnAdopt$ = createEffect(() => {
4223
- return this.actions$.pipe(ofType(streamAdopted), mergeMap(() => {
4224
- const cached = this.regionsPingService.readCachedWinner();
4225
- return [
4226
- regionPingFetchStart(),
4227
- regionPingFetchEnd(),
4228
- regionResolved({
4229
- region: cached?.region_code ?? '',
4230
- pingsMs: cached?.result ?? [],
4231
- }),
4232
- ];
4233
- }));
4234
- });
4235
4235
  }
4236
4236
  canPeerReconnect() {
4237
4237
  return RECONNECT_CONFIG.enabled && this.signallingService.isConnected();
@@ -5533,6 +5533,19 @@ const QP_RED_THRESHOLD = 35;
5533
5533
  const FPS_LOW_RATIO = 0.7; // p50 fps below 70% of target
5534
5534
  const UNLIMITED_REFERENCE_FPS = 60;
5535
5535
  const SAMPLE_WINDOW_MS = 5000;
5536
+ /**
5537
+ * Catalog phase ids the prefetch establishes invisibly. For an adopted
5538
+ * connection their live closers are suppressed and the phases are emitted by
5539
+ * {@link StreamStatusTelemetryService2.emitAdoptedConnection} instead, timed
5540
+ * from the prefetch's own marks and anchored at the moment Angular reached the
5541
+ * connect call (session start) — so the waterfall shows the time the prefetch
5542
+ * actually SAVED, not the prefetch's page-load-parallel raw timeline.
5543
+ */
5544
+ const PREFETCHED_PHASE_IDS = new Set([
5545
+ 'region-resolving',
5546
+ 'signaling-connection',
5547
+ 'webrtc-negotiation',
5548
+ ]);
5536
5549
  // Persistent per-browser identifier — survives page reloads. Used to group
5537
5550
  // multiple sessions of the same anonymous user for funnel / retention analysis.
5538
5551
  const USER_ID_STORAGE_KEY = 'source3d.streamTelemetry.userId';
@@ -5563,6 +5576,17 @@ class StreamStatusTelemetryService2 {
5563
5576
  this.disconnectReason$ = this.store.selectSignal(unrealFeature.selectDisconnectReason);
5564
5577
  this.errorMessage$ = this.store.selectSignal(unrealFeature.selectErrorMessage);
5565
5578
  this.viewPortReady$ = this.store.selectSignal(unrealFeature.selectViewportReady);
5579
+ /**
5580
+ * Synchronous adoption flag. The `streamAdopted` reducer sets it BEFORE any
5581
+ * action subscriber runs, so the connection-phase closers (which fire inside
5582
+ * the same dispatch, e.g. `ssinfo-received` off the `ssData` state change)
5583
+ * can read it and defer to the prefetch-timed attribution emitted by
5584
+ * {@link emitAdoptedConnection} instead of recording the live wall-clock.
5585
+ */
5586
+ this.adopted$ = this.store.selectSignal(unrealFeature.selectAdopted);
5587
+ /** Streamer identity for the adopted signaling phase params. */
5588
+ this.ssData$ = this.store.selectSignal(unrealFeature.selectSsData);
5589
+ this.awsInstance$ = this.store.selectSignal(unrealFeature.selectAwsInstance);
5566
5590
  this.session = null;
5567
5591
  /**
5568
5592
  * Whether a session has already started during the current page lifetime.
@@ -5955,6 +5979,13 @@ class StreamStatusTelemetryService2 {
5955
5979
  if (!this.session)
5956
5980
  return;
5957
5981
  if (isCloser) {
5982
+ // Adopted connections receive the region / signaling / webrtc
5983
+ // phases from `emitAdoptedConnection` (timed from the prefetch's
5984
+ // own marks). Skip the live closer for those, so the whole
5985
+ // pre-adoption wait isn't dumped onto whichever connection closer
5986
+ // happens to fire first at the adoption instant.
5987
+ if (this.adopted$() && PREFETCHED_PHASE_IDS.has(bucket.id))
5988
+ return;
5958
5989
  this.emitPhaseEnded(bucket, bucketIdx, value);
5959
5990
  return;
5960
5991
  }
@@ -5965,6 +5996,12 @@ class StreamStatusTelemetryService2 {
5965
5996
  }));
5966
5997
  });
5967
5998
  });
5999
+ // Adopted connection: emit the region / signaling / webrtc phases timed
6000
+ // from the prefetch marks, anchored at session start (see method doc). The
6001
+ // live closers for these phases are suppressed (PREFETCHED_PHASE_IDS).
6002
+ sub.add(this.actions$
6003
+ .pipe(ofType(streamAdopted))
6004
+ .subscribe(({ timings }) => this.emitAdoptedConnection(timings)));
5968
6005
  const bootstrapInfo = this.collectBootstrapInfo();
5969
6006
  this.emitLifecycle('stream.session_started', {
5970
6007
  sessionId: id,
@@ -6082,6 +6119,130 @@ class StreamStatusTelemetryService2 {
6082
6119
  overlays,
6083
6120
  });
6084
6121
  }
6122
+ /**
6123
+ * Emit the region / signaling / webrtc phases for an ADOPTED connection.
6124
+ *
6125
+ * The prefetch script established the connection in parallel with page load,
6126
+ * so its raw timeline (prefetch_start … ready) mostly predates this session —
6127
+ * showing it verbatim would look identical to a non-prefetched connect. What
6128
+ * matters is measured from the moment Angular reached the connect call
6129
+ * (session start, == `lastMilestonePerf` here): the REAL wait until adoption,
6130
+ * and how much the prefetch SAVED by finishing the handshake during page load.
6131
+ *
6132
+ * So the three phases fill exactly the `[session start → adopted]` window
6133
+ * (split across signaling / webrtc by the prefetch's own sub-step ratio; the
6134
+ * region was served from cache → ~0), and the savings figures ride in params:
6135
+ * - `prefetchConnectMs` — full handshake the prefetch measured (the cost a
6136
+ * cold, non-prefetched connect would pay here).
6137
+ * - `prefetchSavedMs` — how much of it completed before Angular was ready.
6138
+ * - `adoptionWaitMs` — the actual wait Angular experienced (= window).
6139
+ * - `adoptionOverheadMs` — gap between prefetch-ready and adoption.
6140
+ */
6141
+ emitAdoptedConnection(timings) {
6142
+ const s = this.session;
6143
+ if (!s)
6144
+ return;
6145
+ const t = timings ?? {};
6146
+ const start = t['prefetch_start'] ?? s.startedAtPerf;
6147
+ const wsOpen = t['ws_open'] ?? start;
6148
+ const ready = t['ready'] ?? t['dc_open'] ?? wsOpen;
6149
+ // Real prefetch sub-durations — how the handshake actually broke down.
6150
+ const sigRaw = Math.max(0, wsOpen - start);
6151
+ const webRaw = Math.max(0, ready - wsOpen);
6152
+ const connectMs = Math.round(sigRaw + webRaw);
6153
+ // `lastMilestonePerf` is still pinned at session start (the connection
6154
+ // closers are suppressed for adopted sessions), so this is the genuine
6155
+ // connect → adopted wait the user lived through.
6156
+ const now = performance.now();
6157
+ const waitMs = Math.max(0, Math.round(now - s.lastMilestonePerf));
6158
+ // What running the handshake during page load bought us: the part already
6159
+ // done by the time Angular reached the connect call.
6160
+ const savedMs = Math.max(0, Math.min(connectMs, Math.round(s.startedAtPerf - start)));
6161
+ const adoptionOverheadMs = Math.max(0, Math.round(now - ready));
6162
+ const savings = {
6163
+ prefetched: true,
6164
+ prefetchConnectMs: connectMs,
6165
+ prefetchSavedMs: savedMs,
6166
+ adoptionWaitMs: waitMs,
6167
+ adoptionOverheadMs,
6168
+ };
6169
+ // Split the real wait across signaling / webrtc by the prefetch's own
6170
+ // signaling:webrtc proportion.
6171
+ const denom = sigRaw + webRaw;
6172
+ const sigMs = denom > 0 ? Math.round((waitMs * sigRaw) / denom) : 0;
6173
+ const webMs = Math.max(0, waitMs - sigMs);
6174
+ // Region (phaseNo 1) — served from the ping cache during prefetch (~0),
6175
+ // but ship the samples so the phase still carries its data.
6176
+ const region = this.readRegionCache();
6177
+ this.emitAdoptedPhase(0, 0, {
6178
+ region: region?.region_code ?? '',
6179
+ pingsMs: region?.result.length
6180
+ ? `${region.result.join(', ')} ms`
6181
+ : null,
6182
+ prefetched: true,
6183
+ }, []);
6184
+ // Signaling (phaseNo 2) — carries the streamer identity + savings summary.
6185
+ this.emitAdoptedPhase(1, sigMs, {
6186
+ ...this.adoptedSignalingParams(),
6187
+ ...savings,
6188
+ prefetchSignalingMs: Math.round(sigRaw),
6189
+ }, [{ id: 'signaling-ws-connected', time: Math.round(wsOpen) }]);
6190
+ // WebRTC negotiation (phaseNo 3).
6191
+ this.emitAdoptedPhase(2, webMs, {
6192
+ iceCandidateCount: 0,
6193
+ prefetched: true,
6194
+ prefetchWebrtcMs: Math.round(webRaw),
6195
+ }, []);
6196
+ }
6197
+ /** Emit one adopted phase with an explicit (prefetch-derived) duration and
6198
+ * advance `lastMilestonePerf` synthetically so the next phase chains right. */
6199
+ emitAdoptedPhase(bucketIdx, durationMs, extras, milestones) {
6200
+ const s = this.session;
6201
+ if (!s)
6202
+ return;
6203
+ const bucket = this.phaseCatalog[bucketIdx];
6204
+ this.track('stream.phase_ended', {
6205
+ sessionId: s.id,
6206
+ phaseNo: bucketIdx + 1,
6207
+ id: bucket.id,
6208
+ color: bucket.color,
6209
+ duration: durationMs,
6210
+ milestones,
6211
+ params: toPhaseParams(extras),
6212
+ overlays: [],
6213
+ });
6214
+ s.lastMilestonePerf += durationMs;
6215
+ }
6216
+ /** Streamer identity params for the adopted signaling phase — mirrors the
6217
+ * `ssinfo-received` closer's shape (client / instanceName / …ssData). */
6218
+ adoptedSignalingParams() {
6219
+ const aws = this.awsInstance$();
6220
+ const ssData = this.ssData$();
6221
+ return {
6222
+ client: aws?.wsUrl ?? null,
6223
+ instanceName: aws?.instanceName ?? null,
6224
+ ...(ssData ?? {}),
6225
+ };
6226
+ }
6227
+ /** Read the region-ping cache the prefetch reused (localStorage), so the
6228
+ * adopted region phase can still report the winning region + samples. */
6229
+ readRegionCache() {
6230
+ try {
6231
+ const raw = localStorage.getItem(REGION_PING_CACHE_KEY);
6232
+ if (!raw)
6233
+ return null;
6234
+ const parsed = JSON.parse(raw);
6235
+ return {
6236
+ region_code: typeof parsed.region_code === 'string' ? parsed.region_code : '',
6237
+ result: Array.isArray(parsed.result)
6238
+ ? parsed.result.filter((n) => typeof n === 'number')
6239
+ : [],
6240
+ };
6241
+ }
6242
+ catch {
6243
+ return null;
6244
+ }
6245
+ }
6085
6246
  handleWindow(samples) {
6086
6247
  if (!this.session)
6087
6248
  return;