@3dsource/angular-unreal-module 0.0.188 → 0.0.189

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.
@@ -3,7 +3,7 @@ import { input, output, ChangeDetectionStrategy, Component, inject, DestroyRef,
3
3
  import { NgTemplateOutlet, NgOptimizedImage, JsonPipe } from '@angular/common';
4
4
  import { createAction, props, createReducer, on, createFeature, createSelector, Store, provideState } from '@ngrx/store';
5
5
  import { Actions, ofType, createEffect, provideEffects } from '@ngrx/effects';
6
- import { Subject, skip, share, timer, of, take, defer, switchMap, from, throwError, Observable, EMPTY, retry, merge, timeout as timeout$1, repeat, BehaviorSubject, interval, map as map$1, takeUntil as takeUntil$1, fromEvent, tap as tap$1, throttleTime, delay, concat, debounceTime, mergeMap, startWith, takeWhile, NEVER, ignoreElements, scan, timestamp, pairwise, concatMap, animationFrameScheduler, shareReplay, firstValueFrom, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith } from 'rxjs';
6
+ import { Subject, skip, share, timer, of, take, defer, switchMap, from, throwError, Observable, EMPTY, retry, merge, timeout as timeout$1, repeat, BehaviorSubject, interval, takeUntil as takeUntil$1, fromEvent, tap as tap$1, throttleTime, map as map$1, delay, concat, debounceTime, mergeMap, startWith, takeWhile, NEVER, ignoreElements, scan, timestamp, pairwise, concatMap, animationFrameScheduler, shareReplay, firstValueFrom, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith } from 'rxjs';
7
7
  import { UNREAL_CONFIG } from '@3dsource/angular-unreal-module/config';
8
8
  export { UNREAL_CONFIG } from '@3dsource/angular-unreal-module/config';
9
9
  import { tap, filter, takeUntil, catchError, map, timeout, finalize, first, switchMap as switchMap$1, distinctUntilChanged, withLatestFrom, exhaustMap, takeWhile as takeWhile$1, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
@@ -2991,6 +2991,9 @@ class VideoService extends SubService {
2991
2991
  // would drop that emission, so the stats interval (and `setViewportReady`,
2992
2992
  // gated on `framesPerSecond`) would never start.
2993
2993
  this.videoTrack$ = new BehaviorSubject(null);
2994
+ /** Bumped per attached peer connection: candidate-pair ids are only unique
2995
+ * inside one connection, so the epoch scopes the telemetry route ref. */
2996
+ this.pcEpoch = 0;
2994
2997
  this.VideoEncoderQP = 0;
2995
2998
  this.aggregatedStats = {};
2996
2999
  /**
@@ -2999,7 +3002,7 @@ class VideoService extends SubService {
2999
3002
  this.videoStats$ = this.videoTrack$.pipe(
3000
3003
  // If videoTrack$ emits null, switch to EMPTY → the current interval is canceled.
3001
3004
  switchMap$1((track) => track
3002
- ? interval(250).pipe(map$1(() => track.pcClient), switchMap$1((pcClient) => from(this.getStats(pcClient))), filter(Truthy))
3005
+ ? interval(250).pipe(switchMap$1(() => from(this.getStats(track.pcClient, track.pcEpoch))), filter(Truthy))
3003
3006
  : of(null)), filter(Truthy), share());
3004
3007
  this.destroyAbort = new AbortController();
3005
3008
  // Teardown is wired ONCE here instead of per `create()`. The per-create
@@ -3082,7 +3085,7 @@ class VideoService extends SubService {
3082
3085
  this.audio.srcObject = new MediaStream(audioTracks);
3083
3086
  this.playAudio(this.audio);
3084
3087
  }
3085
- this.videoTrack$.next({ stream, pcClient });
3088
+ this.videoTrack$.next({ stream, pcClient, pcEpoch: ++this.pcEpoch });
3086
3089
  // Safari does not reliably honor the autoplay attribute when srcObject
3087
3090
  // is bound post-creation for a WebRTC MediaStream; trigger play()
3088
3091
  // explicitly and let safePlay() fall back if Safari refuses.
@@ -3165,7 +3168,7 @@ class VideoService extends SubService {
3165
3168
  signal: this.destroyAbort.signal,
3166
3169
  });
3167
3170
  }
3168
- async getStats(pcClient) {
3171
+ async getStats(pcClient, pcEpoch = 0) {
3169
3172
  if (!pcClient || pcClient.connectionState === 'closed') {
3170
3173
  return null;
3171
3174
  }
@@ -3176,7 +3179,7 @@ class VideoService extends SubService {
3176
3179
  if (!this.video) {
3177
3180
  return null;
3178
3181
  }
3179
- return this.generateAggregatedStatsFunction(stats);
3182
+ return this.generateAggregatedStatsFunction(stats, pcEpoch);
3180
3183
  }
3181
3184
  catch (err) {
3182
3185
  // Handle InvalidStateError or any transient WebRTC errors gracefully
@@ -3187,8 +3190,10 @@ class VideoService extends SubService {
3187
3190
  throw err;
3188
3191
  }
3189
3192
  }
3190
- generateAggregatedStatsFunction(stats) {
3193
+ generateAggregatedStatsFunction(stats, pcEpoch = 0) {
3191
3194
  const newStat = {};
3195
+ let selectedPairId;
3196
+ let flaggedPairId;
3192
3197
  // store each type of codec we can get stats on
3193
3198
  newStat.codecs = {};
3194
3199
  newStat.currentRoundTripTime = -1;
@@ -3197,7 +3202,9 @@ class VideoService extends SubService {
3197
3202
  if (report.type === 'inbound-rtp' && report.kind === 'video') {
3198
3203
  this.applyInboundVideoReport(newStat, report);
3199
3204
  }
3200
- // Only read RTT from the *nominated* ICE candidate pair. The
3205
+ // Fallback RTT for when no selected pair resolves —
3206
+ // `applySelectedPair` overrides it with the selected pair's RTT.
3207
+ // Only read it from the *nominated* ICE candidate pair. The
3201
3208
  // stats report contains multiple `candidate-pair` entries (one
3202
3209
  // per host/srflx/relay combination tried during ICE); only the
3203
3210
  // nominated pair carries a meaningful `currentRoundTripTime`,
@@ -3212,9 +3219,13 @@ class VideoService extends SubService {
3212
3219
  Object.hasOwn(report, 'currentRoundTripTime')) {
3213
3220
  newStat.currentRoundTripTime = report.currentRoundTripTime ?? 0;
3214
3221
  }
3215
- if (report.type === 'transport' &&
3216
- Object.hasOwn(report, 'selectedCandidatePairId')) {
3217
- this.applySelectedPair(newStat, stats, report.selectedCandidatePairId);
3222
+ if (report.type === 'transport' && report.selectedCandidatePairId) {
3223
+ selectedPairId = report.selectedCandidatePairId;
3224
+ }
3225
+ // Firefox reports no `transport` stats; its pre-standard `selected`
3226
+ // flag marks the same pair and is used only when the transport is absent.
3227
+ if (report.type === 'candidate-pair' && report.selected === true) {
3228
+ flaggedPairId = report.id;
3218
3229
  }
3219
3230
  // Store mimetype of each codec
3220
3231
  if (Object.hasOwn(newStat, 'codecs') &&
@@ -3227,6 +3238,7 @@ class VideoService extends SubService {
3227
3238
  .replace('audio/', '');
3228
3239
  }
3229
3240
  });
3241
+ this.applySelectedPair(newStat, stats, selectedPairId ?? flaggedPairId, pcEpoch);
3230
3242
  this.applySessionTotals(newStat);
3231
3243
  this.aggregatedStats = newStat;
3232
3244
  return this.onAggregatedStats(newStat);
@@ -3268,24 +3280,37 @@ class VideoService extends SubService {
3268
3280
  }
3269
3281
  }
3270
3282
  /**
3271
- * Resolves the transport's selected candidate pair into the local/remote
3272
- * descriptors shown in the stats overlay. Silently skips when the pair (or
3273
- * either of its candidates) is missing from this report.
3283
+ * Resolves the selected candidate pair into its route description, byte
3284
+ * counters and RTT. Anything missing from this report stays unknown, and a
3285
+ * malformed report never throws into the stats loop — the route is optional,
3286
+ * playback is not.
3274
3287
  */
3275
- applySelectedPair(newStat, stats, selectedCandidatePairId) {
3276
- if (!selectedCandidatePairId) {
3288
+ applySelectedPair(newStat, stats, pairId, pcEpoch) {
3289
+ newStat.selectedPair = null;
3290
+ if (!pairId) {
3277
3291
  return;
3278
3292
  }
3279
- const selectedPair = stats.get(selectedCandidatePairId);
3280
- if (!selectedPair) {
3281
- return;
3293
+ try {
3294
+ const pair = stats.get(pairId);
3295
+ if (!pair) {
3296
+ return;
3297
+ }
3298
+ newStat.selectedPair = {
3299
+ ref: `${pcEpoch}:${pairId}`,
3300
+ timestamp: finite(pair.timestamp),
3301
+ bytesReceived: finite(pair.bytesReceived),
3302
+ bytesSent: finite(pair.bytesSent),
3303
+ local: candidateInfo(stats.get(pair.localCandidateId), true),
3304
+ remote: candidateInfo(stats.get(pair.remoteCandidateId), false),
3305
+ };
3306
+ const rtt = finite(pair.currentRoundTripTime);
3307
+ if (rtt !== undefined) {
3308
+ newStat.currentRoundTripTime = rtt;
3309
+ }
3310
+ }
3311
+ catch {
3312
+ newStat.selectedPair = null;
3282
3313
  }
3283
- const local = stats.get(selectedPair.localCandidateId);
3284
- const remote = stats.get(selectedPair.remoteCandidateId);
3285
- newStat.selectedPair = {
3286
- local: { type: local?.candidateType, url: local?.url },
3287
- remote: { type: remote?.candidateType, url: remote?.url },
3288
- };
3289
3314
  }
3290
3315
  /** Per-session values that do not come from a single report. */
3291
3316
  applySessionTotals(newStat) {
@@ -3401,6 +3426,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
3401
3426
  function pinBaseline(pinned, current) {
3402
3427
  return (pinned || current);
3403
3428
  }
3429
+ function finite(value) {
3430
+ return typeof value === 'number' && Number.isFinite(value)
3431
+ ? value
3432
+ : undefined;
3433
+ }
3434
+ /**
3435
+ * The route-relevant fields of one candidate. The address is uploaded for a
3436
+ * relay candidate only — host / srflx addresses identify the user, a relay
3437
+ * address identifies the TURN provider's region.
3438
+ */
3439
+ function candidateInfo(candidate, isLocal) {
3440
+ if (!candidate) {
3441
+ return {};
3442
+ }
3443
+ const isRelay = candidate.candidateType === 'relay';
3444
+ return {
3445
+ type: candidate.candidateType,
3446
+ protocol: candidate.protocol,
3447
+ ...(isRelay
3448
+ ? {
3449
+ address: candidate.address ?? undefined,
3450
+ port: finite(candidate.port),
3451
+ }
3452
+ : {}),
3453
+ ...(isLocal
3454
+ ? { url: candidate.url, relayProtocol: candidate.relayProtocol }
3455
+ : {}),
3456
+ };
3457
+ }
3404
3458
  /**
3405
3459
  * Whether a rejected `play()` was blocked by the browser autoplay policy.
3406
3460
  * Narrows an `unknown` rejection value without assuming it is an Error — a
@@ -6799,6 +6853,56 @@ class CounterWindow {
6799
6853
  }
6800
6854
  }
6801
6855
 
6856
+ /**
6857
+ * Turns one `stream.sample` window of 250 ms stats reads into its route
6858
+ * fields: the runs of reads that shared a selected pair, and the description
6859
+ * of every pair this session has not described yet.
6860
+ *
6861
+ * One per session, because the described refs are that session's: the
6862
+ * collector files each session separately, so every file must describe the
6863
+ * routes it references.
6864
+ *
6865
+ * Only the reads that are there are reported. A read without a selected pair
6866
+ * closes the run, and nothing is interpolated across it — the server sees the
6867
+ * gap between two segments and keeps it unattributed.
6868
+ */
6869
+ class RouteLog {
6870
+ constructor() {
6871
+ this.described = new Set();
6872
+ }
6873
+ take(samples) {
6874
+ const routeSegments = [];
6875
+ const routes = {};
6876
+ let open = null;
6877
+ for (const { aggregatedStats } of samples) {
6878
+ const pair = aggregatedStats.selectedPair;
6879
+ if (!pair) {
6880
+ open = null;
6881
+ continue;
6882
+ }
6883
+ const ts = pair.timestamp === undefined ? null : Math.round(pair.timestamp);
6884
+ const rx = pair.bytesReceived ?? null;
6885
+ const tx = pair.bytesSent ?? null;
6886
+ if (open?.ref === pair.ref) {
6887
+ open.ts[1] = ts;
6888
+ open.rx[1] = rx;
6889
+ open.tx[1] = tx;
6890
+ continue;
6891
+ }
6892
+ open = { ref: pair.ref, ts: [ts, ts], rx: [rx, rx], tx: [tx, tx] };
6893
+ routeSegments.push(open);
6894
+ if (!this.described.has(pair.ref)) {
6895
+ this.described.add(pair.ref);
6896
+ routes[pair.ref] = { local: pair.local, remote: pair.remote };
6897
+ }
6898
+ }
6899
+ return {
6900
+ ...(routeSegments.length ? { routeSegments } : {}),
6901
+ ...(Object.keys(routes).length ? { routes } : {}),
6902
+ };
6903
+ }
6904
+ }
6905
+
6802
6906
  /**
6803
6907
  * Telemetry for the WebRTC stream lifecycle.
6804
6908
  *
@@ -7217,6 +7321,7 @@ class StreamStatusTelemetryService2 {
7217
7321
  seq: 0,
7218
7322
  sub,
7219
7323
  bytes: new CounterWindow(),
7324
+ routes: new RouteLog(),
7220
7325
  qpBad: false,
7221
7326
  fpsLow: false,
7222
7327
  maxFpsTarget: UNLIMITED_REFERENCE_FPS,
@@ -7661,6 +7766,7 @@ class StreamStatusTelemetryService2 {
7661
7766
  width: latest?.frameWidth,
7662
7767
  height: latest?.frameHeight,
7663
7768
  },
7769
+ ...s.routes.take(samples),
7664
7770
  });
7665
7771
  this.evaluateAnomalies(fps, qp);
7666
7772
  }
@@ -7922,7 +8028,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
7922
8028
  /** Compact one side of an ICE candidate pair to `"type (url)"` or just
7923
8029
  * `type` (host candidates have no URL). */
7924
8030
  function fmtCandidate(c) {
7925
- if (!c) {
8031
+ if (!c?.type) {
7926
8032
  return null;
7927
8033
  }
7928
8034
  return c.url ? `${c.type} (${c.url})` : c.type;
@@ -8695,17 +8801,42 @@ class VideoStatsComponent {
8695
8801
  value: this.stats()?.aggregatedStats.VideoEncoderQP ?? 0,
8696
8802
  }), /* @ts-ignore */
8697
8803
  ...(ngDevMode ? [{ debugName: "videoQP" }] : /* istanbul ignore next */ []));
8698
- this.videoStatus = computed(() => {
8804
+ /** What the window shows: the raw stats plus the few display-only values
8805
+ * derived here. Shared by the text rows and the graphs. */
8806
+ this.view = computed(() => {
8699
8807
  const event = this.stats();
8700
- if (!this.isDevMode || !event) {
8808
+ if (!event) {
8701
8809
  return undefined;
8702
8810
  }
8703
- const data = {
8704
- ...event.aggregatedStats,
8811
+ const stats = event.aggregatedStats;
8812
+ const pair = stats.selectedPair;
8813
+ const view = {
8814
+ ...stats,
8705
8815
  quality: event.quality,
8816
+ MBytesReceived: typeof stats.bytesReceived === 'number'
8817
+ ? +(stats.bytesReceived / 1e6).toFixed(2)
8818
+ : undefined,
8819
+ // Only the stable part of the route: counters, stats timestamp and ref
8820
+ // change on every tick and would make the row jump.
8821
+ selectedPair: pair && {
8822
+ local: {
8823
+ type: pair.local.type,
8824
+ protocol: pair.local.protocol,
8825
+ url: pair.local.url,
8826
+ },
8827
+ remote: { type: pair.remote.type, protocol: pair.remote.protocol },
8828
+ },
8706
8829
  };
8830
+ return view;
8831
+ }, /* @ts-ignore */
8832
+ ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
8833
+ this.videoStatus = computed(() => {
8834
+ const view = this.view();
8835
+ if (!this.isDevMode || !view) {
8836
+ return undefined;
8837
+ }
8707
8838
  return this.fields
8708
- .map((key) => ({ key, value: data[key] }))
8839
+ .map((key) => ({ key, value: view[key] }))
8709
8840
  .filter((r) => !this.elementsToShow().includes(r.key));
8710
8841
  }, /* @ts-ignore */
8711
8842
  ...(ngDevMode ? [{ debugName: "videoStatus" }] : /* istanbul ignore next */ []));
@@ -8740,9 +8871,7 @@ class VideoStatsComponent {
8740
8871
  Math.floor(Math.random() * 16777216)
8741
8872
  .toString(16)
8742
8873
  .padStart(6, '0'),
8743
- stat: computed(() => ({
8744
- value: this.stats()?.aggregatedStats[key],
8745
- })),
8874
+ stat: computed(() => ({ value: this.view()?.[key] })),
8746
8875
  }));
8747
8876
  this.graphList = computed(() => this.graphListItems.filter((r) => this.elementsToShow().includes(r.key)), /* @ts-ignore */
8748
8877
  ...(ngDevMode ? [{ debugName: "graphList" }] : /* istanbul ignore next */ []));