@3dsource/angular-unreal-module 0.0.110 → 0.0.112

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,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, ChangeDetectionStrategy, Component, Injectable, DestroyRef, effect, untracked, signal, Pipe, input, ElementRef, viewChild, afterNextRender, HostListener, computed, output, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
3
3
  import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-ui-native';
4
- import { merge, map, scan, concatMap, of, animationFrameScheduler, interval, Observable, debounceTime, switchMap, take, fromEvent, EMPTY, timer, timeout, mergeMap, Subject, skip, share, from, throwError, defer, retry, tap as tap$1, startWith, takeUntil as takeUntil$1, auditTime, filter as filter$1, throttleTime, BehaviorSubject, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1, concat } from 'rxjs';
4
+ import { merge, map, scan, concatMap, of, animationFrameScheduler, interval, Observable, debounceTime, switchMap, take, fromEvent, EMPTY, timer, timeout, mergeMap, Subject, skip, share, from, throwError, defer, retry, tap as tap$1, startWith, takeUntil as takeUntil$1, throttleTime, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, pairwise, Subscription, bufferTime, BehaviorSubject, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1, concat } from 'rxjs';
5
5
  import { createAction, props, createReducer, on, createFeature, createSelector, Store, provideState } from '@ngrx/store';
6
6
  import { Actions, createEffect, ofType, provideEffects } from '@ngrx/effects';
7
- import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, withLatestFrom, exhaustMap, catchError, takeUntil, switchMap as switchMap$1, timeout as timeout$1, debounceTime as debounceTime$1, delay, skip as skip$1 } from 'rxjs/operators';
7
+ import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, withLatestFrom, exhaustMap, catchError, takeUntil, switchMap as switchMap$1, timeout as timeout$1, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1, delay, skip as skip$1 } from 'rxjs/operators';
8
8
  import { Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, whereNot, Truthy, Falsy, tapLog, generateUuid, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, Semaphore, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
9
9
  import { concatLatestFrom } from '@ngrx/operators';
10
10
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
@@ -12,6 +12,7 @@ import { HttpClient } from '@angular/common/http';
12
12
  import { ScrollStrategyOptions } from '@angular/cdk/overlay';
13
13
  import { MetaBoxCommand, EPayloadVersion } from '@3dsource/types-unreal';
14
14
  import { toSignal, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
15
+ import { v4 } from 'uuid';
15
16
  import { DomSanitizer } from '@angular/platform-browser';
16
17
  import { NgOptimizedImage, AsyncPipe, JsonPipe } from '@angular/common';
17
18
  import * as i1 from '@angular/forms';
@@ -150,6 +151,7 @@ const commandCompleted = createAction(scoped$1 `command completed`, props());
150
151
 
151
152
  const scoped = (templateString) => `[UNREAL] ${templateString[0]}`;
152
153
  const trackMixpanelEvent = createAction(scoped `track mixpanel event`, props());
154
+ const trackTelemetryData = createAction(scoped `track telemetry data`, props());
153
155
  const changeLowBandwidth = createAction(scoped `change low bandwidth`, props());
154
156
  const setMaxFps = createAction(scoped `change fps`, props());
155
157
  const destroyRemoteConnections = createAction(scoped `destroyRemoteConnections`, props());
@@ -184,6 +186,47 @@ const changeStreamResolutionAction = createAction(scoped `change stream resoluti
184
186
  const changeStreamResolutionSuccessAction = createAction(scoped `change stream resolution success action`, props());
185
187
  const setSignalingName = createAction(scoped `set aws instanceName`, props());
186
188
  const setOrchestrationParameters = createAction(scoped `set orchestration parameters`, props());
189
+ /**
190
+ * Dispatched when the orchestration server signals that the reserved
191
+ * instance is ready to serve the stream (`instanceReady` WS message).
192
+ * Emitted for observability only — no reducer state change.
193
+ */
194
+ const instanceReady = createAction(scoped `instance ready`);
195
+ /**
196
+ * Dispatched when `RegionsPingService.getFastest()` begins — right
197
+ * before the region-list HTTP request goes out. Bookends the entire
198
+ * region-discovery flow together with {@link regionResolved}.
199
+ * Observability-only — no state change.
200
+ */
201
+ const regionPingFetchStart = createAction(scoped `region ping fetch start`);
202
+ /**
203
+ * Dispatched once `RegionsPingService.getProviders()` returns the region
204
+ * list — i.e. the fetch step is done and the actual per-region ping
205
+ * sweep is about to start. Splits the region-discovery waterfall into
206
+ * two legs: config fetch vs ping sweep. Observability-only.
207
+ */
208
+ const regionPingFetchEnd = createAction(scoped `region ping fetch end`);
209
+ /**
210
+ * Dispatched once the closest region has been resolved by the
211
+ * regions-ping service. Carries every sampled ping RTT to the winning
212
+ * region (one entry per probe — `checkLatency` issues `ping_count`
213
+ * sequential pings per region) so consumers can inspect variance /
214
+ * jitter, not just the mean. Observability-only — no state change.
215
+ */
216
+ const regionResolved = createAction(scoped `region resolved`, props());
217
+ /**
218
+ * Dispatched around the HTTP polling roundtrip inside `getAwsInstance`
219
+ * (old orchestration flow). Only fires when polling is actually used
220
+ * (i.e. signaling URL is not a direct `ws://` / `wss://` endpoint).
221
+ * Observability-only — no state change.
222
+ */
223
+ const pollingStarted = createAction(scoped `polling started`);
224
+ const pollingEnded = createAction(scoped `polling ended`);
225
+ /**
226
+ * Dispatched when the client has just sent an SDP answer back to Cirrus
227
+ * after receiving the server-initiated offer. Observability-only.
228
+ */
229
+ const answerSent = createAction(scoped `answer sent`);
187
230
  const setOrchestrationMessage = createAction(scoped `set orchestration message`, props());
188
231
  const setOrchestrationProgress = createAction(scoped `set orchestration progress`, props());
189
232
  const setOrchestrationContext = createAction(scoped `set orchestration params`, props());
@@ -508,7 +551,6 @@ const unrealReducer = createReducer(initialState$1, on(changeLowBandwidth, (stat
508
551
  }), on(setUnrealPlaywrightConfig, (state) => {
509
552
  return {
510
553
  ...state,
511
- isFirstSuccessLoad: true,
512
554
  cirrusConnected: true,
513
555
  viewportReady: true,
514
556
  dataChannelConnected: true,
@@ -541,7 +583,9 @@ function IceCandidateHandler(msg) {
541
583
  }
542
584
 
543
585
  function InstanceReadyHandler() {
544
- //Do nothing here
586
+ // No state change — emit the action purely so services that want to
587
+ // observe orchestration progress (e.g. telemetry) have a hook.
588
+ this.store.dispatch(instanceReady());
545
589
  }
546
590
 
547
591
  function InstanceReservedHandler(msg) {
@@ -1169,6 +1213,7 @@ const selectTotalProgress = createSelector(unrealFeature.selectStatusPercentSign
1169
1213
  });
1170
1214
  const selectClientAndViewIds = createSelector(unrealFeature.selectStreamClientCompanyId, unrealFeature.selectStreamViewId, (clientId, viewId) => ({ clientId, viewId }));
1171
1215
  const selectIsLoaderScreenVisible = createSelector(unrealFeature.selectViewportReady, unrealFeature.selectDataChannelConnected, unrealFeature.selectCirrusConnected, (viewReady, cirrusConnected, dataChannelConnected) => !viewReady || !cirrusConnected || !dataChannelConnected);
1216
+ const selectCirrusConnectionId = createSelector(unrealFeature.selectSsData, (data) => data?.connectionId);
1172
1217
 
1173
1218
  class UnrealErrorModalComponent {
1174
1219
  constructor() {
@@ -2580,7 +2625,9 @@ class SignallingService extends SubService {
2580
2625
  this.correlationId = generateUuid();
2581
2626
  this.commandTelemetryService.reset();
2582
2627
  this.startEstablishingConnection();
2583
- return defer(() => this.getRegion().pipe(tap((region) => this.region.set(region ?? '')), map$1((region) => this.adaptUrlsToRegion(urlList, region)), switchMap((urls) => this.getAwsInstance(urls).pipe(take(1))), tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) => this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1))), takeUntil(this.abort$))).pipe(
2628
+ return defer(() => {
2629
+ return this.getRegion().pipe(tap((region) => this.region.set(region ?? '')), map$1((region) => this.adaptUrlsToRegion(urlList, region)), switchMap((urls) => this.getAwsInstance(urls).pipe(take(1))), tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) => this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1))), takeUntil(this.abort$));
2630
+ }).pipe(
2584
2631
  // Retry the whole attempt on error, advancing to the next URL each time.
2585
2632
  retry({
2586
2633
  delay: (err, attempt) => {
@@ -2714,6 +2761,9 @@ class SignallingService extends SubService {
2714
2761
  };
2715
2762
  }));
2716
2763
  }
2764
+ // Old (HTTP-polling) orchestration flow — mark the roundtrip so
2765
+ // the signaling-phase timeline can show it as a distinct segment.
2766
+ this.store.dispatch(pollingStarted());
2717
2767
  return this.store.select(selectClientAndViewIds).pipe(
2718
2768
  // Log missing IDs without altering the stream.
2719
2769
  tap(({ clientId, viewId }) => {
@@ -2746,6 +2796,7 @@ class SignallingService extends SubService {
2746
2796
  }),
2747
2797
  // Map a valid orchestration payload into the final AwsInstance.
2748
2798
  map$1((data) => {
2799
+ this.store.dispatch(pollingEnded());
2749
2800
  const wsUrl = httpUrlToWs(`${location.protocol}//${data.signallingServer}`);
2750
2801
  return {
2751
2802
  wsUrl,
@@ -3413,6 +3464,9 @@ class WebRtcPlayerService extends SubService {
3413
3464
  answer.sdp = this.mungeSDP(answer.sdp ?? '');
3414
3465
  await pcClient.setLocalDescription(answer);
3415
3466
  this.signallingSrv.send(answer);
3467
+ // Observability signal for the signaling waterfall — the handshake
3468
+ // went "answer sent" between `offer received` and `datachannel_ready`.
3469
+ this.store.dispatch(answerSent());
3416
3470
  }
3417
3471
  catch (e) {
3418
3472
  Logger.error("Couldn't create answer", e);
@@ -4485,6 +4539,7 @@ class InputService extends SubService {
4485
4539
  class RegionsPingService {
4486
4540
  constructor() {
4487
4541
  this.httpClient = inject(HttpClient);
4542
+ this.store = inject(Store);
4488
4543
  this.unrealInitialConfig = inject(UNREAL_CONFIG, { optional: true });
4489
4544
  this.config = {
4490
4545
  ping_count: 2,
@@ -4494,7 +4549,15 @@ class RegionsPingService {
4494
4549
  };
4495
4550
  }
4496
4551
  getFastest(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4497
- return this.getProviders(regionListUrl).pipe(switchMap((providers) => this.getPingResult(providers)), catchError(() => of(null)), tapLog('PingResult'), map$1((data) => data?.region_code));
4552
+ // Self-announce every lifecycle waypoint of the region-discovery
4553
+ // flow (start / providers-fetched / resolved). The final
4554
+ // `regionResolved` forwards the winner's full ping-sample array
4555
+ // straight from `PingResult.result` — no aggregation.
4556
+ this.store.dispatch(regionPingFetchStart());
4557
+ return this.getProviders(regionListUrl).pipe(tap(() => this.store.dispatch(regionPingFetchEnd())), switchMap((providers) => this.getPingResult(providers)), catchError(() => of(null)), tapLog('PingResult'), tap((data) => this.store.dispatch(regionResolved({
4558
+ region: data?.region_code ?? '',
4559
+ pingsMs: data?.result ?? [],
4560
+ }))), map$1((data) => data?.region_code));
4498
4561
  }
4499
4562
  getPingResult(providers) {
4500
4563
  const regions = providers.regions;
@@ -4583,11 +4646,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.10", ngImpo
4583
4646
  }] });
4584
4647
 
4585
4648
  class StreamStatusTelemetryService {
4586
- #store;
4587
- #videoService;
4588
4649
  constructor() {
4589
- this.#store = inject(Store);
4590
- this.#videoService = inject(VideoService);
4650
+ this.store = inject(Store);
4651
+ this.actions$ = inject(Actions);
4652
+ this.videoService = inject(VideoService);
4591
4653
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4592
4654
  this.init();
4593
4655
  }
@@ -4595,13 +4657,47 @@ class StreamStatusTelemetryService {
4595
4657
  if (this.unrealConfig?.playwright) {
4596
4658
  return;
4597
4659
  }
4598
- this.#videoService.videoStats$
4599
- .pipe(withLatestFrom(this.#store.select(unrealFeature.selectAwsInstance), this.#store.select(unrealFeature.selectLowBandwidth), this.#store.select(unrealFeature.selectLowBandwidthStats), this.#store.select(unrealFeature.selectCirrusConnected)), auditTime(5000), filter(([, , , , cirrusConnected]) => cirrusConnected), map$1(([data, signalingServer, isLowBandwidth, lbmStats]) => this.mapEventData(data, signalingServer, isLowBandwidth, lbmStats)))
4660
+ // Start a telemetry session on every `initSignalling` dispatch.
4661
+ // switchMap cancels the previous session's inner pipeline on re-init.
4662
+ // throttleTime leading+trailing emits the first event immediately and
4663
+ // at most one event every 5s afterward.
4664
+ this.actions$
4665
+ .pipe(ofType(initSignalling), switchMap$1(() => {
4666
+ // Captured once per session — marks the UTC moment signaling started.
4667
+ const startedAt = Date.now();
4668
+ const startTimeUtc = new Date(startedAt).toISOString();
4669
+ const signalingReadyTime$ = this.readyTimeSince(this.store.select(unrealFeature.selectCirrusConnected), startedAt);
4670
+ const viewPortReadyTime$ = this.readyTimeSince(this.store.select(unrealFeature.selectViewportReady), startedAt);
4671
+ return this.videoService.videoStats$.pipe(throttleTime(5000, asyncScheduler, {
4672
+ leading: true,
4673
+ trailing: true,
4674
+ }), withLatestFrom(this.store.select(unrealFeature.selectAwsInstance), this.store.select(unrealFeature.selectLowBandwidth), this.store.select(unrealFeature.selectLowBandwidthStats), signalingReadyTime$, viewPortReadyTime$), map$1(([data, signalingServer, isLowBandwidth, lbmStats, signalingReadyTime, viewPortReadyTime,]) => this.mapEventData({
4675
+ data,
4676
+ signalingServer,
4677
+ isLowBandwidth,
4678
+ lbmStats,
4679
+ startTimeUtc,
4680
+ signalingReadyTime,
4681
+ viewPortReadyTime,
4682
+ })));
4683
+ }))
4600
4684
  .subscribe((data) => this.trackEventToMixPanel(data));
4601
4685
  }
4602
- mapEventData(data, signalingServer, isLowBandwidth, lbmStats) {
4686
+ /**
4687
+ * Emits `undefined` until `readyState$` first emits a truthy value,
4688
+ * then emits the ms elapsed since `startedAt` and completes.
4689
+ * Used with `withLatestFrom` so events carry a stable duration for the whole session.
4690
+ */
4691
+ readyTimeSince(readyState$, startedAt) {
4692
+ return readyState$.pipe(filter(Boolean), take$1(1), map$1(() => Date.now() - startedAt), startWith$1(undefined));
4693
+ }
4694
+ mapEventData(params) {
4695
+ const { data, signalingServer, isLowBandwidth, lbmStats, startTimeUtc, signalingReadyTime, viewPortReadyTime, } = params;
4603
4696
  const { bitrate, VideoEncoderQP, framesPerSecond, frameWidth, frameHeight, jitter, packetsLost, currentRoundTripTime, runTime, } = data.aggregatedStats;
4604
4697
  return {
4698
+ startTimeUtc,
4699
+ signalingReadyTime,
4700
+ viewPortReadyTime,
4605
4701
  runTime,
4606
4702
  frameWidth,
4607
4703
  frameHeight,
@@ -4619,7 +4715,7 @@ class StreamStatusTelemetryService {
4619
4715
  };
4620
4716
  }
4621
4717
  trackEventToMixPanel(data) {
4622
- this.#store.dispatch(trackMixpanelEvent({ event: 'streamStatus', data }));
4718
+ this.store.dispatch(trackMixpanelEvent({ event: 'streamStatus', data }));
4623
4719
  }
4624
4720
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: StreamStatusTelemetryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4625
4721
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: StreamStatusTelemetryService }); }
@@ -4628,6 +4724,707 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.10", ngImpo
4628
4724
  type: Injectable
4629
4725
  }], ctorParameters: () => [] });
4630
4726
 
4727
+ const QP_RED_THRESHOLD = 35;
4728
+ const FPS_LOW_RATIO = 0.7; // p50 fps below 70% of target
4729
+ const UNLIMITED_REFERENCE_FPS = 60;
4730
+ const SAMPLE_WINDOW_MS = 5000;
4731
+ // Persistent per-browser identifier — survives page reloads. Used to group
4732
+ // multiple sessions of the same anonymous user for funnel / retention analysis.
4733
+ const USER_ID_STORAGE_KEY = 'sourceui.streamTelemetry.userId';
4734
+ class StreamStatusTelemetryService2 {
4735
+ constructor() {
4736
+ this.store = inject(Store);
4737
+ this.actions$ = inject(Actions);
4738
+ this.videoService = inject(VideoService);
4739
+ this.signallingService = inject(SignallingService);
4740
+ this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4741
+ /**
4742
+ * Per-browser anonymous user id. Read from `localStorage` on first access;
4743
+ * if missing, a new UUID is generated and persisted. Included as
4744
+ * `data.userId` on every emitted event — the server uses it to bucket
4745
+ * per-session files under `data/<userId>/<sessionId>.jsonl`.
4746
+ */
4747
+ this.userId = this.getOrCreateUserId();
4748
+ this.session = null;
4749
+ /**
4750
+ * Whether a session has already started during the current page lifetime.
4751
+ * Used to distinguish a fresh page load (bootstrap measurement is
4752
+ * meaningful) from a Resume/restart (the document is already loaded,
4753
+ * so "bootstrap" is a no-op). Only the first session gets the bootstrap
4754
+ * phase anchored at navigation start (perf.now() === 0).
4755
+ */
4756
+ this.hasStartedBefore = false;
4757
+ /**
4758
+ * Rolling snapshot of the currently selected WebRTC ICE candidate pair
4759
+ * (local/remote type + url). Scanned over `videoStats$` so the stream
4760
+ * *keeps* the first non-null pair even if later stats emit null — WebRTC
4761
+ * doesn't un-select a pair mid-session, but the source is nullable. The
4762
+ * `startWith(null)` seed makes `withLatestFrom` on this stream safe even
4763
+ * before the first video stat arrives (common at data-channel-open).
4764
+ */
4765
+ this.selectedPair$ = this.videoService.videoStats$.pipe(map((s) => s.aggregatedStats.selectedPair ?? null), scan((acc, curr) => curr ?? acc, null), startWith(null));
4766
+ /**
4767
+ * Correlation-matched Observable: emits once when the first `loopBack`
4768
+ * command's corresponding `command completed` action fires. Built from
4769
+ * the two related actions — the command-started captures the correlation
4770
+ * id, the command-completed with that id emits.
4771
+ */
4772
+ this.loopbackReceived$ = this.actions$.pipe(ofType(commandStarted), filter$1(({ command }) => command === 'loopBack'), take(1), switchMap(({ id }) => this.actions$.pipe(ofType(commandCompleted), filter$1((b) => b.id === id), take(1))));
4773
+ // Overlay-referenced milestones — hoisted as fields so both
4774
+ // `milestones: [...]` and `overlays: [...]` share the same object
4775
+ // identity (the in-session Map keys milestones by that identity).
4776
+ this.pollingStartedEntry = {
4777
+ label: 'Polling started (HTTP orchestration)',
4778
+ event: this.actions$.pipe(ofType(pollingStarted)),
4779
+ };
4780
+ this.awsInstanceResolvedEntry = {
4781
+ label: 'Instance WS URL resolved',
4782
+ event: this.actions$.pipe(ofType(setAwsInstance)),
4783
+ };
4784
+ this.loopbackReceivedEntry = {
4785
+ label: 'LoopBack received',
4786
+ event: this.loopbackReceived$,
4787
+ };
4788
+ this.signalingServerConnectedEntry = {
4789
+ label: 'Websocket connected',
4790
+ event: this.store
4791
+ .select(unrealFeature.selectCirrusConnected)
4792
+ .pipe(filter$1(Boolean), take(1)),
4793
+ };
4794
+ this.configReceivedEntry = {
4795
+ label: 'Config received',
4796
+ event: this.signallingService.onConfig$.pipe(filter$1(Boolean), take(1)),
4797
+ };
4798
+ /**
4799
+ * Unified phase catalog — the single source of truth for everything
4800
+ * this service tracks after `session_started`. Each entry is a phase;
4801
+ * its `milestones` array is ordered chronologically and the *last*
4802
+ * entry is the phase closer. When that closer's observable fires, the
4803
+ * service emits one generic `stream.phase_ended` wire event with every
4804
+ * display field the viewer needs.
4805
+ *
4806
+ * `phaseNo` is the array index; `startPhase` chains from the previous
4807
+ * phase's `endPhase` — neither needs to be specified here.
4808
+ */
4809
+ this.phaseCatalog = [
4810
+ {
4811
+ label: 'Region resolving',
4812
+ color: '#61afef',
4813
+ milestones: [
4814
+ {
4815
+ label: 'Region ping fetch start',
4816
+ event: this.actions$.pipe(ofType(regionPingFetchStart)),
4817
+ },
4818
+ {
4819
+ label: 'Region ping fetch end',
4820
+ event: this.actions$.pipe(ofType(regionPingFetchEnd)),
4821
+ },
4822
+ // CLOSER — the `regionResolved` action carries the raw ping
4823
+ // samples (one per probe) from `RegionsPingService`; the
4824
+ // catalog flattens them to a comma-separated string since
4825
+ // `toPhaseParams` only accepts primitives.
4826
+ {
4827
+ label: 'Region resolved',
4828
+ event: this.actions$.pipe(ofType(regionResolved), take(1), map(({ region, pingsMs }) => ({
4829
+ region,
4830
+ pingsMs: pingsMs.length ? `${pingsMs.join(', ')} ms` : null,
4831
+ }))),
4832
+ },
4833
+ ],
4834
+ },
4835
+ {
4836
+ label: 'Signaling connection',
4837
+ color: '#c678dd',
4838
+ overlays: [
4839
+ {
4840
+ from: this.pollingStartedEntry,
4841
+ to: this.awsInstanceResolvedEntry,
4842
+ title: 'Polling → instance resolved (HTTP polling + AWS instance handle)',
4843
+ },
4844
+ {
4845
+ from: this.signalingServerConnectedEntry,
4846
+ to: this.configReceivedEntry,
4847
+ title: 'Signaling handshake (WS open → Cirrus config received)',
4848
+ },
4849
+ ],
4850
+ milestones: [
4851
+ this.pollingStartedEntry,
4852
+ {
4853
+ label: 'Polling ended (HTTP orchestration)',
4854
+ event: this.actions$.pipe(ofType(pollingEnded)),
4855
+ },
4856
+ this.awsInstanceResolvedEntry,
4857
+ this.signalingServerConnectedEntry,
4858
+ {
4859
+ label: 'Instance reserved (WS orchestration)',
4860
+ event: this.actions$.pipe(ofType(setOrchestrationParameters)),
4861
+ },
4862
+ {
4863
+ label: 'Instance ready (WS orchestration)',
4864
+ event: this.actions$.pipe(ofType(instanceReady)),
4865
+ },
4866
+ this.configReceivedEntry,
4867
+ {
4868
+ label: 'SSInfo received',
4869
+ event: this.store.select(unrealFeature.selectSsData).pipe(filter$1(Boolean), take(1), withLatestFrom$1(this.store.select(unrealFeature.selectAwsInstance)), map(([ssData, aws]) => ({
4870
+ client: aws.wsUrl,
4871
+ instanceName: aws.instanceName,
4872
+ ...ssData,
4873
+ }))),
4874
+ },
4875
+ ],
4876
+ },
4877
+ {
4878
+ label: 'WebRTC negotiation',
4879
+ color: '#e5c07b',
4880
+ milestones: [
4881
+ {
4882
+ label: 'Offer received',
4883
+ event: this.signallingService.onOffer$.pipe(filter$1(Boolean)),
4884
+ },
4885
+ {
4886
+ label: 'Answer sent (server-offer flow)',
4887
+ event: this.actions$.pipe(ofType(answerSent)),
4888
+ },
4889
+ {
4890
+ label: 'Answer received (client-offer flow)',
4891
+ event: this.signallingService.onWebRtcAnswer$.pipe(filter$1(Boolean)),
4892
+ },
4893
+ {
4894
+ label: 'First ICE candidate from server',
4895
+ event: this.signallingService.onWebRtcIce$.pipe(filter$1(Boolean)),
4896
+ },
4897
+ {
4898
+ label: 'Last ICE candidate from server',
4899
+ event: this.signallingService.onWebRtcIce$.pipe(filter$1(Boolean)),
4900
+ mode: 'last',
4901
+ },
4902
+ // CLOSER — snaps negotiation diagnostics and the Cirrus
4903
+ // streamer identity (`ssData`, reliably populated by DC-open
4904
+ // time) into phase-3 params:
4905
+ // - `iceCandidateCount` (total ICE candidates from server — too
4906
+ // few hints at a restrictive network)
4907
+ // - `handshakeFlow` (server-offer vs client-offer; we only use
4908
+ // server-offer today, but the detector stays in place)
4909
+ // - `instanceType` / `branchInfo` / version / path / app /
4910
+ // `connectionId` (streamer identity, from `ssInfo` message)
4911
+ {
4912
+ label: 'Data channel connected',
4913
+ event: this.store
4914
+ .select(unrealFeature.selectDataChannelConnected)
4915
+ .pipe(filter$1(Boolean), take(1), map(() => ({
4916
+ iceCandidateCount: this.session?.iceCandidateCount ?? 0,
4917
+ handshakeFlow: this.session?.handshakeFlow ?? null,
4918
+ }))),
4919
+ },
4920
+ ],
4921
+ },
4922
+ {
4923
+ label: 'Unreal communication',
4924
+ color: '#98c379',
4925
+ overlays: [
4926
+ {
4927
+ from: this.loopbackReceivedEntry,
4928
+ title: 'Stabilization (warm up)',
4929
+ },
4930
+ ],
4931
+ milestones: [
4932
+ {
4933
+ label: 'First command started',
4934
+ event: this.actions$.pipe(ofType(commandStarted)),
4935
+ },
4936
+ {
4937
+ label: 'LoopBack sent',
4938
+ event: this.actions$.pipe(ofType(commandStarted), filter$1(({ command }) => command === 'loopBack')),
4939
+ },
4940
+ this.loopbackReceivedEntry,
4941
+ // CLOSER — flattens the selected ICE candidate pair into two
4942
+ // string params (local / remote), and snapshots the running
4943
+ // command counter so phase 5 shows how many commands the client
4944
+ // fired at Unreal before the viewport was ready.
4945
+ {
4946
+ label: 'Viewport ready',
4947
+ event: this.store.select(unrealFeature.selectViewportReady).pipe(filter$1(Boolean), take(1), withLatestFrom$1(this.selectedPair$), map(([, selectedPair]) => ({
4948
+ localCandidate: fmtCandidate(selectedPair?.local),
4949
+ remoteCandidate: fmtCandidate(selectedPair?.remote),
4950
+ commandCount: this.session?.commandCount ?? 0,
4951
+ }))),
4952
+ },
4953
+ ],
4954
+ },
4955
+ ];
4956
+ if (this.unrealConfig?.playwright)
4957
+ return;
4958
+ if (!this.unrealConfig?.streamTelemetryV2)
4959
+ return;
4960
+ this.init();
4961
+ }
4962
+ getOrCreateUserId() {
4963
+ try {
4964
+ const existing = localStorage.getItem(USER_ID_STORAGE_KEY);
4965
+ if (existing && existing.length > 0)
4966
+ return existing;
4967
+ const fresh = v4();
4968
+ localStorage.setItem(USER_ID_STORAGE_KEY, fresh);
4969
+ return fresh;
4970
+ }
4971
+ catch {
4972
+ // localStorage disabled (private-mode restrictions, quota, etc.)
4973
+ // Fall back to a per-tab ephemeral id so telemetry keeps working.
4974
+ return v4();
4975
+ }
4976
+ }
4977
+ init() {
4978
+ // Start a new session on every initSignalling dispatch.
4979
+ this.actions$
4980
+ .pipe(ofType(initSignalling))
4981
+ .subscribe(() => this.startSession());
4982
+ // End the current session when Cirrus drops (true → false transition).
4983
+ this.store
4984
+ .select(unrealFeature.selectCirrusConnected)
4985
+ .pipe(pairwise(), filter$1(([prev, curr]) => prev && !curr))
4986
+ .subscribe(() => this.endSession('disconnected'));
4987
+ // Close the session when the tab is hidden / being evicted from bfcache.
4988
+ // `pagehide` fires in more cases than `beforeunload` (covers iOS Safari, bfcache).
4989
+ fromEvent(window, 'pagehide').subscribe(() => this.endSession('pagehide'));
4990
+ }
4991
+ startSession() {
4992
+ // If a previous session is still active, close it first.
4993
+ this.endSession('restart');
4994
+ const id = v4();
4995
+ const startedAt = Date.now();
4996
+ const startedAtPerf = performance.now();
4997
+ const sub = new Subscription();
4998
+ // Anchor selection:
4999
+ // - First session on this page → anchor at navigation start (0).
5000
+ // `performance.now()` is already "ms since navigation start", so
5001
+ // `session_started.duration` becomes the full page-load + idle-time
5002
+ // leading up to the first stream start (i.e. the real bootstrap).
5003
+ // - Resume/restart sessions → anchor at the session's own start.
5004
+ // The document is already loaded — a "bootstrap" phase would either
5005
+ // balloon (to include all prior idle time) or be misleading, so we
5006
+ // suppress it by making the anchor == session_started.
5007
+ const isFirstSession = !this.hasStartedBefore;
5008
+ this.hasStartedBefore = true;
5009
+ const anchorPerf = isFirstSession ? 0 : startedAtPerf;
5010
+ const times = new Map();
5011
+ this.session = {
5012
+ id,
5013
+ startedAt,
5014
+ startedAtPerf,
5015
+ anchorPerf,
5016
+ // Initial milestone anchor = anchorPerf. For the first session this is
5017
+ // navigation start (0), so `session_started.duration` becomes the full
5018
+ // page-load elapsed. For Resume sessions anchor == startedAtPerf, so
5019
+ // `session_started.duration` is 0 (no bootstrap phase).
5020
+ lastMilestonePerf: anchorPerf,
5021
+ seq: 0,
5022
+ sub,
5023
+ qpBad: false,
5024
+ fpsLow: false,
5025
+ maxFpsTarget: UNLIMITED_REFERENCE_FPS,
5026
+ iceCandidateCount: 0,
5027
+ handshakeFlow: null,
5028
+ commandCount: 0,
5029
+ times,
5030
+ };
5031
+ // Side-channel counters for WebRTC negotiation diagnostics — shown
5032
+ // as phase-3 params on the `datachannel_connected` closer. Kept
5033
+ // imperative (session-scoped) so no RxJS hot-stream plumbing is
5034
+ // needed to survive the subscribe-on-close timing.
5035
+ sub.add(this.signallingService.onWebRtcIce$
5036
+ .pipe(filter$1(Boolean))
5037
+ .subscribe(() => {
5038
+ if (this.session)
5039
+ this.session.iceCandidateCount++;
5040
+ }));
5041
+ sub.add(this.actions$.pipe(ofType(answerSent), take(1)).subscribe(() => {
5042
+ if (this.session)
5043
+ this.session.handshakeFlow = 'server-offer';
5044
+ }));
5045
+ sub.add(this.signallingService.onWebRtcAnswer$
5046
+ .pipe(filter$1(Boolean), take(1))
5047
+ .subscribe(() => {
5048
+ if (this.session)
5049
+ this.session.handshakeFlow = 'client-offer';
5050
+ }));
5051
+ // Unreal-command counter — increments on every `commandStarted`
5052
+ // action. Snapshotted into the `viewport_ready` closer payload as
5053
+ // phase-5's `commandCount`.
5054
+ sub.add(this.actions$.pipe(ofType(commandStarted)).subscribe(() => {
5055
+ if (this.session)
5056
+ this.session.commandCount++;
5057
+ }));
5058
+ // Unified catalog loop — one subscription per entry. Non-closers
5059
+ // record their emission time into the session map. The *last* entry
5060
+ // of each phase is the closer: its emission fires a single generic
5061
+ // `stream.phase_ended` event carrying every display field the viewer
5062
+ // needs. The viewer never inspects milestone names or phase kinds;
5063
+ // adding a new phase to `phaseCatalog` is enough — zero viewer work.
5064
+ this.phaseCatalog.forEach((bucket, bucketIdx) => {
5065
+ bucket.milestones.forEach((entry, idx) => {
5066
+ const isCloser = idx === bucket.milestones.length - 1;
5067
+ sub.add(entry.event.subscribe((value) => {
5068
+ if (!this.session)
5069
+ return;
5070
+ if (isCloser) {
5071
+ this.emitPhaseEnded(bucket, bucketIdx, value);
5072
+ return;
5073
+ }
5074
+ const time = Math.round(performance.now() - this.session.anchorPerf);
5075
+ if (entry.mode !== 'last' && this.session.times.has(entry))
5076
+ return;
5077
+ this.session.times.set(entry, time);
5078
+ }));
5079
+ });
5080
+ });
5081
+ const bootstrapInfo = this.collectBootstrapInfo();
5082
+ this.emitLifecycle('stream.session_started', {
5083
+ sessionId: id,
5084
+ startTimeUtc: new Date(startedAt).toISOString(),
5085
+ pixelRatio: window.devicePixelRatio,
5086
+ // Browser viewport size at session start (`window.innerWidth` /
5087
+ // `innerHeight`). Named `browserInnerSize` to distinguish from
5088
+ // the physical screen — what gates the rendered video resolution
5089
+ // is the container we started from, not the device hardware.
5090
+ browserInnerSize: {
5091
+ width: window.innerWidth,
5092
+ height: window.innerHeight,
5093
+ },
5094
+ userAgent: navigator.userAgent,
5095
+ sampleWindowMs: SAMPLE_WINDOW_MS,
5096
+ // Full page URL — `location.href` is the only reliable way to get this.
5097
+ // The server-observed `Referer` header is truncated to origin for
5098
+ // cross-origin requests by default Referrer-Policy.
5099
+ pageUrl: window.location.href,
5100
+ // Navigation / page-load context for the "app bootstrap → session_started"
5101
+ // waterfall phase. Only useful for the first session (`bootstrap` phase
5102
+ // is suppressed on Resume), but `bootstrapInfo` describes the document,
5103
+ // not the session — harmless and cheap to send either way.
5104
+ ...(bootstrapInfo && { bootstrapInfo }),
5105
+ },
5106
+ // Pin the milestone to the captured `performance.now()` so `duration`
5107
+ // equals "ms since navigation start" precisely (for first sessions),
5108
+ // rather than being inflated by any synchronous setup done in between.
5109
+ startedAtPerf);
5110
+ // Track current maxFps target to evaluate FPS-ratio anomalies.
5111
+ sub.add(this.actions$
5112
+ .pipe(ofType(setMaxFps))
5113
+ .subscribe(({ maxFps }) => this.updateMaxFpsTarget(maxFps)));
5114
+ // Periodic aggregated sample — every 5s window.
5115
+ sub.add(this.videoService.videoStats$
5116
+ .pipe(bufferTime(SAMPLE_WINDOW_MS), filter$1((samples) => samples.length > 0))
5117
+ .subscribe((samples) => this.handleWindow(samples)));
5118
+ }
5119
+ endSession(reason) {
5120
+ if (!this.session)
5121
+ return;
5122
+ const { id, sub } = this.session;
5123
+ this.emitLifecycle('stream.session_ended', {
5124
+ sessionId: id,
5125
+ reason,
5126
+ timestamp: new Date().toISOString(),
5127
+ });
5128
+ sub.unsubscribe();
5129
+ this.session = null;
5130
+ }
5131
+ /**
5132
+ * Emit a lifecycle event with a `duration` field — ms between the
5133
+ * previous lifecycle event and now (phase-local, NOT anchor-relative).
5134
+ * For `session_started` this equals the full page-load elapsed on the
5135
+ * first session, or 0 on Resume. Advances `lastMilestonePerf` so the
5136
+ * next event's duration is correctly rooted.
5137
+ */
5138
+ emitLifecycle(event, extra, atPerf) {
5139
+ if (!this.session)
5140
+ return;
5141
+ // `atPerf` lets callers pin the milestone to a specific `performance.now()`
5142
+ // value — useful for `session_started` where the wall-clock and perf-clock
5143
+ // anchors were already captured at the top of `startSession()`.
5144
+ const now = atPerf ?? performance.now();
5145
+ const duration = Math.round(now - this.session.lastMilestonePerf);
5146
+ this.track(event, { duration, ...extra });
5147
+ this.session.lastMilestonePerf = now;
5148
+ }
5149
+ /**
5150
+ * Emit the unified `stream.phase_ended` wire event for a closer. Packs
5151
+ * every display field the viewer consumes — phaseNo (derived from the
5152
+ * catalog index), label, color, milestones, params, overlays. Boundary
5153
+ * labels aren't on the wire — the viewer derives them from neighbour
5154
+ * phases' `label` fields.
5155
+ */
5156
+ emitPhaseEnded(bucket, bucketIdx, extrasValue) {
5157
+ if (!this.session)
5158
+ return;
5159
+ const now = performance.now();
5160
+ const phaseStartMs = Math.round(this.session.lastMilestonePerf - this.session.anchorPerf);
5161
+ const phaseEndMs = Math.round(now - this.session.anchorPerf);
5162
+ const milestones = this.milestonesOfBucket(bucket);
5163
+ const params = toPhaseParams(extrasValue);
5164
+ const overlays = resolveOverlays(bucket.overlays, this.session.times, phaseStartMs, phaseEndMs);
5165
+ this.emitLifecycle('stream.phase_ended', {
5166
+ sessionId: this.session.id,
5167
+ phaseNo: bucketIdx + 1,
5168
+ label: bucket.label,
5169
+ color: bucket.color,
5170
+ milestones,
5171
+ params,
5172
+ overlays,
5173
+ });
5174
+ }
5175
+ handleWindow(samples) {
5176
+ if (!this.session)
5177
+ return;
5178
+ const s = this.session;
5179
+ const bitrate = this.aggregate(samples.map((x) => x.aggregatedStats.bitrate ?? 0));
5180
+ const fps = this.aggregate(samples.map((x) => x.aggregatedStats.framesPerSecond ?? 0));
5181
+ const qp = this.aggregate(samples.map((x) => x.aggregatedStats.VideoEncoderQP ?? 0));
5182
+ const jitter = this.aggregate(samples.map((x) => x.aggregatedStats.jitter ?? 0));
5183
+ const rtt = this.aggregate(samples.map((x) => x.aggregatedStats.currentRoundTripTime ?? 0));
5184
+ const latest = samples[samples.length - 1]?.aggregatedStats;
5185
+ // Periodic sample event.
5186
+ this.track('stream.sample', {
5187
+ sessionId: s.id,
5188
+ seq: s.seq++,
5189
+ timestamp: new Date().toISOString(),
5190
+ sampleCount: samples.length,
5191
+ // Tab visibility state at emission time. Chrome does NOT throttle timers
5192
+ // for WebRTC-active tabs, so the handler runs on time even when hidden —
5193
+ // the point-in-time read is reliable in this specific context.
5194
+ visibilityState: document.visibilityState,
5195
+ bitrate,
5196
+ fps,
5197
+ qp,
5198
+ jitter,
5199
+ rtt,
5200
+ packetsLost: latest?.packetsLost,
5201
+ viewPort: {
5202
+ width: latest?.frameWidth,
5203
+ height: latest?.frameHeight,
5204
+ },
5205
+ });
5206
+ this.evaluateAnomalies(fps, qp);
5207
+ }
5208
+ evaluateAnomalies(fps, qp) {
5209
+ if (!this.session)
5210
+ return;
5211
+ const s = this.session;
5212
+ // Quality (QP red) entry/exit.
5213
+ const qpBadNow = qp.p50 > QP_RED_THRESHOLD;
5214
+ if (qpBadNow !== s.qpBad) {
5215
+ s.qpBad = qpBadNow;
5216
+ this.track(qpBadNow ? 'stream.quality_degraded' : 'stream.quality_recovered', {
5217
+ sessionId: s.id,
5218
+ seq: s.seq,
5219
+ timestamp: new Date().toISOString(),
5220
+ qpP50: qp.p50,
5221
+ qpP95: qp.p95,
5222
+ });
5223
+ }
5224
+ // FPS low vs current target.
5225
+ const ref = s.maxFpsTarget > 0 ? s.maxFpsTarget : UNLIMITED_REFERENCE_FPS;
5226
+ const fpsLowNow = fps.p50 < ref * FPS_LOW_RATIO;
5227
+ if (fpsLowNow && !s.fpsLow) {
5228
+ this.track('stream.fps_low', {
5229
+ sessionId: s.id,
5230
+ seq: s.seq,
5231
+ timestamp: new Date().toISOString(),
5232
+ target: s.maxFpsTarget,
5233
+ referenceTarget: ref,
5234
+ fpsP50: fps.p50,
5235
+ fpsP95: fps.p95,
5236
+ });
5237
+ }
5238
+ s.fpsLow = fpsLowNow;
5239
+ }
5240
+ updateMaxFpsTarget(maxFps) {
5241
+ if (this.session)
5242
+ this.session.maxFpsTarget = maxFps;
5243
+ }
5244
+ /**
5245
+ * Snapshot every milestone recorded in this session that belongs to
5246
+ * {@link phase}. Used to attach a phase's milestone array to the
5247
+ * lifecycle event that closes that phase.
5248
+ */
5249
+ /**
5250
+ * Collect wire-shape milestones that fired within a bucket. Walks the
5251
+ * catalog's own milestone list (minus the closer) and pulls each
5252
+ * entry's time from the in-session map via object identity.
5253
+ */
5254
+ milestonesOfBucket(bucket) {
5255
+ if (!this.session)
5256
+ return [];
5257
+ const out = [];
5258
+ const closerIdx = bucket.milestones.length - 1;
5259
+ for (let i = 0; i < closerIdx; i++) {
5260
+ const entry = bucket.milestones[i];
5261
+ const time = this.session.times.get(entry);
5262
+ if (time !== undefined)
5263
+ out.push({ label: entry.label, time });
5264
+ }
5265
+ return out;
5266
+ }
5267
+ /**
5268
+ * Capture browser navigation timings — useful to explain the
5269
+ * "application bootstrap → session_started" waterfall phase.
5270
+ * Values are ms from navigation start; null if the entry is unavailable
5271
+ * (old browsers, cross-origin iframes with limited perf access, etc.).
5272
+ */
5273
+ collectBootstrapInfo() {
5274
+ try {
5275
+ const nav = performance.getEntriesByType('navigation')[0];
5276
+ if (!nav)
5277
+ return null;
5278
+ return {
5279
+ type: nav.type, // 'navigate' | 'reload' | 'back_forward' | 'prerender'
5280
+ protocol: nav.nextHopProtocol || null, // 'h2' / 'h3' / 'http/1.1'
5281
+ firstByteMs: Math.round(nav.responseStart),
5282
+ domInteractiveMs: Math.round(nav.domInteractive),
5283
+ domContentLoadedMs: Math.round(nav.domContentLoadedEventEnd),
5284
+ loadEventMs: Math.round(nav.loadEventEnd),
5285
+ // Navigation-only size — the HTML document itself. Usually small;
5286
+ // the interesting number is `resources.transferSize` below.
5287
+ navigationTransferSize: nav.transferSize || null,
5288
+ navigationEncodedBodySize: nav.encodedBodySize || null,
5289
+ navigationDecodedBodySize: nav.decodedBodySize || null,
5290
+ resources: this.collectResourceTotals(),
5291
+ };
5292
+ }
5293
+ catch {
5294
+ return null;
5295
+ }
5296
+ }
5297
+ /**
5298
+ * Aggregate all resources the browser has loaded so far
5299
+ * (`performance.getEntriesByType('resource')`), grouped by `initiatorType`.
5300
+ * Gives a real "bytes downloaded before session start" figure — unlike the
5301
+ * navigation entry which only covers the HTML document.
5302
+ *
5303
+ * `transferSize === 0 && decodedBodySize > 0` means the resource came from
5304
+ * the HTTP cache — counted separately so cached hits don't inflate
5305
+ * the "bytes on the wire" metric.
5306
+ */
5307
+ collectResourceTotals() {
5308
+ try {
5309
+ const list = performance.getEntriesByType('resource');
5310
+ if (!list.length)
5311
+ return null;
5312
+ const empty = () => ({
5313
+ count: 0,
5314
+ cachedCount: 0,
5315
+ transferSize: 0,
5316
+ encodedBodySize: 0,
5317
+ decodedBodySize: 0,
5318
+ });
5319
+ const totals = empty();
5320
+ const byType = {};
5321
+ for (const r of list) {
5322
+ const type = r.initiatorType || 'other';
5323
+ const b = (byType[type] ||= empty());
5324
+ const fromCache = r.transferSize === 0 && r.decodedBodySize > 0;
5325
+ const t = r.transferSize || 0;
5326
+ const enc = r.encodedBodySize || 0;
5327
+ const dec = r.decodedBodySize || 0;
5328
+ b.count++;
5329
+ if (fromCache)
5330
+ b.cachedCount++;
5331
+ b.transferSize += t;
5332
+ b.encodedBodySize += enc;
5333
+ b.decodedBodySize += dec;
5334
+ totals.count++;
5335
+ if (fromCache)
5336
+ totals.cachedCount++;
5337
+ totals.transferSize += t;
5338
+ totals.encodedBodySize += enc;
5339
+ totals.decodedBodySize += dec;
5340
+ }
5341
+ return { ...totals, byType };
5342
+ }
5343
+ catch {
5344
+ return null;
5345
+ }
5346
+ }
5347
+ aggregate(values) {
5348
+ if (values.length === 0)
5349
+ return { p50: 0, p95: 0, min: 0, max: 0 };
5350
+ const sorted = [...values].sort((a, b) => a - b);
5351
+ // Nearest-rank method: p-th percentile ≡ sorted[ceil(p/100 * n) - 1].
5352
+ // Using `floor` would make p95 collapse to `max` for n=20
5353
+ // (floor(0.95*20) = 19 = max index), hiding the max line behind p95
5354
+ // in charts. `ceil - 1` keeps them distinct: p95 = sorted[18], max = sorted[19].
5355
+ const pick = (p) => {
5356
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
5357
+ return sorted[Math.max(0, Math.min(sorted.length - 1, idx))];
5358
+ };
5359
+ return {
5360
+ p50: pick(50),
5361
+ p95: pick(95),
5362
+ min: sorted[0],
5363
+ max: sorted[sorted.length - 1],
5364
+ };
5365
+ }
5366
+ track(event, data) {
5367
+ // `userId` is attached to every event so the server can bucket files
5368
+ // under `data/<userId>/<sessionId>.jsonl` without maintaining a
5369
+ // session→user index.
5370
+ this.store.dispatch(trackTelemetryData({
5371
+ event,
5372
+ data: { userId: this.userId, ...data },
5373
+ }));
5374
+ }
5375
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: StreamStatusTelemetryService2, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5376
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: StreamStatusTelemetryService2 }); }
5377
+ }
5378
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: StreamStatusTelemetryService2, decorators: [{
5379
+ type: Injectable
5380
+ }], ctorParameters: () => [] });
5381
+ // ──────────────────────────────────────────────────────────────────
5382
+ // Module-scope helpers — keep the class body focused on RxJS wiring.
5383
+ // ──────────────────────────────────────────────────────────────────
5384
+ /** Compact one side of an ICE candidate pair to `"type (url)"` or just
5385
+ * `type` (host candidates have no URL). */
5386
+ function fmtCandidate(c) {
5387
+ if (!c)
5388
+ return null;
5389
+ return c.url ? `${c.type} (${c.url})` : c.type;
5390
+ }
5391
+ /**
5392
+ * Flatten the closer's emitted extras object into display key/value
5393
+ * pairs. Null/undefined/empty are dropped; objects and arrays are
5394
+ * skipped — they should be pre-formatted by the closer's `map(...)`.
5395
+ */
5396
+ function toPhaseParams(value) {
5397
+ if (!value || typeof value !== 'object')
5398
+ return [];
5399
+ const out = [];
5400
+ for (const [key, v] of Object.entries(value)) {
5401
+ if (v === null || v === undefined || v === '')
5402
+ continue;
5403
+ if (typeof v === 'object')
5404
+ continue;
5405
+ out.push({ key, value: String(v) });
5406
+ }
5407
+ return out;
5408
+ }
5409
+ /**
5410
+ * Resolve declarative overlay recipes to absolute ms spans. Endpoints
5411
+ * fall back to the phase's own start/end when unspecified; recipes
5412
+ * whose referenced entry never fired are quietly skipped.
5413
+ */
5414
+ function resolveOverlays(recipes, times, phaseStartMs, phaseEndMs) {
5415
+ if (!recipes?.length)
5416
+ return [];
5417
+ const out = [];
5418
+ for (const r of recipes) {
5419
+ const fromMs = r.from ? (times.get(r.from) ?? null) : phaseStartMs;
5420
+ const toMs = r.to ? (times.get(r.to) ?? null) : phaseEndMs;
5421
+ if (fromMs === null || toMs === null || toMs <= fromMs)
5422
+ continue;
5423
+ out.push({ fromMs, toMs, title: r.title });
5424
+ }
5425
+ return out;
5426
+ }
5427
+
4631
5428
  const ReceivedMimeTypes = {
4632
5429
  ApplicationJson: 'application/json',
4633
5430
  };
@@ -6089,12 +6886,14 @@ function provideUnrealModule() {
6089
6886
  AFKService,
6090
6887
  SignallingService,
6091
6888
  StreamStatusTelemetryService,
6889
+ StreamStatusTelemetryService2,
6092
6890
  FpsMonitorService,
6093
6891
  WebRtcPlayerService,
6094
6892
  provideEnvironmentInitializer(() => {
6095
6893
  inject(AggregatorService);
6096
6894
  inject(InputService);
6097
6895
  inject(StreamStatusTelemetryService);
6896
+ inject(StreamStatusTelemetryService2);
6098
6897
  inject(ConsoleExtensionsService);
6099
6898
  inject(AFKService);
6100
6899
  inject(FreezeFrameService);
@@ -6108,5 +6907,5 @@ function provideUnrealModule() {
6108
6907
  * Generated bundle index. Do not edit.
6109
6908
  */
6110
6909
 
6111
- export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, changeLowBandwidth, changeStatusMainVideoOnScene, changeStreamResolutionAction, changeStreamResolutionSuccessAction, commandCompleted, commandStarted, commandsFeature, commandsReducer, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeStudioValuesOld, getResizeValuesUniversal, getResizeValuesUniversalOld, getRtcErrorMessage, iceConnectionFailed, initSignalling, keepMaxUntilReset, makeEven, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setDataChannelConnected, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setKalmanSettings, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setViewportNotReady, setViewportReady, showUnrealErrorMessage, smoothTransition, startStream, trackMixpanelEvent, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
6910
+ export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, StreamStatusTelemetryService2, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, answerSent, changeLowBandwidth, changeStatusMainVideoOnScene, changeStreamResolutionAction, changeStreamResolutionSuccessAction, commandCompleted, commandStarted, commandsFeature, commandsReducer, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeStudioValuesOld, getResizeValuesUniversal, getResizeValuesUniversalOld, getRtcErrorMessage, iceConnectionFailed, initSignalling, instanceReady, keepMaxUntilReset, makeEven, pollingEnded, pollingStarted, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, regionPingFetchEnd, regionPingFetchStart, regionResolved, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectCirrusConnectionId, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setDataChannelConnected, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setKalmanSettings, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setViewportNotReady, setViewportReady, showUnrealErrorMessage, smoothTransition, startStream, trackMixpanelEvent, trackTelemetryData, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
6112
6911
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map