@3dsource/angular-unreal-module 0.0.118 → 0.0.119

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,11 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, ChangeDetectionStrategy, Component, Injectable, DestroyRef, effect, untracked, computed, signal, Pipe, input, ElementRef, viewChild, afterNextRender, HostListener, output, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
3
3
  import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-ui-native';
4
- import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, from, fromEvent, throwError, defer, retry, tap as tap$1, NEVER, throttleTime, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
4
+ import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, shareReplay, from, fromEvent, throwError, defer, retry, tap as tap$1, throttleTime, 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, 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, takeUntil, catchError, exhaustMap, switchMap as switchMap$1, timeout as timeout$1, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
7
+ import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, takeUntil, catchError, withLatestFrom, exhaustMap, switchMap as switchMap$1, timeout as timeout$1, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
8
8
  import { Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, whereNot, Truthy, tapLog, Falsy, generateUuid, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, Semaphore, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
9
+ import { concatLatestFrom } from '@ngrx/operators';
9
10
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
10
11
  import { HttpClient } from '@angular/common/http';
11
12
  import { ScrollStrategyOptions } from '@angular/cdk/overlay';
@@ -312,7 +313,7 @@ const setAfkTimerVisible = createAction(scoped `set afk timer visible`);
312
313
  const setAfkTimerHide = createAction(scoped `set afk timer hide`);
313
314
  const setAfkTimerCountdown = createAction(scoped `set afk timer count down`, props());
314
315
  const showUnrealErrorMessage = createAction(scoped `show unreal error message`, props());
315
- const initSignalling = createAction(scoped `init signalling`, (resetDisconnectionReason = true) => ({ resetDisconnectionReason }));
316
+ const initSignalling = createAction(scoped `init signalling`);
316
317
  const startStream = createAction(scoped `startStream`, props());
317
318
  const resetConfig = createAction(scoped `reset config`);
318
319
  const resetAfk = createAction(scoped `reset afk action`);
@@ -418,14 +419,15 @@ const unrealReducer = createReducer(initialState$1, on(changeStatusMainVideoOnSc
418
419
  freezeFrame.progress === null
419
420
  ? freezeFrame.dataUrl
420
421
  : state.freezeFrame.dataUrl,
421
- progress: freezeFrame.progress || null,
422
+ progress: freezeFrame.progress ?? null,
422
423
  },
423
424
  };
424
425
  }), on(disconnectStream, (state, errorMessage) => {
425
- const alreadyHandled = state.dataChannelConnected ||
426
- state.disconnectReason === DisconnectReason.Destroy;
427
- if (alreadyHandled)
426
+ // Skip user-explicit destroy: the user already initiated cleanup, no
427
+ // disconnect message should overwrite that intent.
428
+ if (state.disconnectReason === DisconnectReason.Destroy) {
428
429
  return state;
430
+ }
429
431
  return { ...state, errorMessage, statusMessage: null };
430
432
  }), on(setFreezeFrameFromVideo, (state, freezeFrameFromVideo) => {
431
433
  return {
@@ -466,13 +468,8 @@ const unrealReducer = createReducer(initialState$1, on(changeStatusMainVideoOnSc
466
468
  warnTimeout: state.userInactivityTimeout,
467
469
  },
468
470
  };
469
- }), on(initSignalling, (state, { resetDisconnectionReason = true }) => {
470
- return {
471
- ...state,
472
- disconnectReason: resetDisconnectionReason
473
- ? DisconnectReason.None
474
- : state.disconnectReason,
475
- };
471
+ }), on(initSignalling, (state) => {
472
+ return { ...state, disconnectReason: DisconnectReason.None };
476
473
  }), on(destroyRemoteConnections, (state, { reason }) => {
477
474
  if (state.disconnectReason === DisconnectReason.Destroy) {
478
475
  return state;
@@ -557,6 +554,10 @@ const unrealReducer = createReducer(initialState$1, on(changeStatusMainVideoOnSc
557
554
  ...initialState$1,
558
555
  establishingConnection: state.establishingConnection,
559
556
  disconnectReason: state.disconnectReason,
557
+ // Preserve the disconnect error so the UI can render the reason
558
+ // after the connection drops. Reset back to null on the next
559
+ // successful initSignalling (initialState default applies again).
560
+ errorMessage: state.errorMessage,
560
561
  isFirstSuccessLoad: state.isFirstSuccessLoad,
561
562
  matchUrls: state.matchUrls,
562
563
  streamClientCompanyId: state.streamClientCompanyId,
@@ -677,10 +678,18 @@ function OnMessageHandler(e) {
677
678
  this.handleMessage(msg);
678
679
  };
679
680
  if (typeof e.data === 'string') {
680
- handleData(e.data);
681
+ try {
682
+ handleData(e.data);
683
+ }
684
+ catch (err) {
685
+ Logger.warn('OnMessageHandler: failed to parse string message', err);
686
+ }
681
687
  }
682
688
  else if (e.data instanceof Blob) {
683
- e.data.text().then(handleData);
689
+ e.data
690
+ .text()
691
+ .then(handleData)
692
+ .catch((err) => Logger.warn('OnMessageHandler: failed to parse Blob message', err));
684
693
  }
685
694
  }
686
695
 
@@ -852,12 +861,15 @@ const getImageFromVideoStream = (takeSizeFrom = 'video', imageOutput, sizes) =>
852
861
  };
853
862
 
854
863
  /**
855
- * Generator for loop through urls
856
- * Each time it will return the next url, based on an index which MUST NOT be re-initialized inside function
857
- * @param urls
864
+ * Generator that cycles through `urls` indefinitely. Each call to
865
+ * `getActiveUrl(urls)` returns a fresh generator with its own counter,
866
+ * so concurrent generators (e.g., overlapping reconnect attempts) do
867
+ * not share state. The counter is initialized once per generator
868
+ * instance — before any `yield` — so each `.next()` call advances
869
+ * within that single generator.
858
870
  */
859
- let index = 0;
860
871
  function* getActiveUrl(urls) {
872
+ let index = 0;
861
873
  while (true) {
862
874
  yield urls[index++ % urls.length];
863
875
  }
@@ -1079,15 +1091,14 @@ function getResizeValuesUniversal(params) {
1079
1091
  return { w: makeEven(w), h: makeEven(h), x: 0, y: 0, scale: w / maxSpace.w };
1080
1092
  }
1081
1093
 
1082
- const removeExileCommands = (state, idToRemove) => {
1094
+ const removeExileCommands = (state, idToRemove, now) => {
1083
1095
  const exileTimeout = 10000;
1084
1096
  const out = { ...state };
1085
1097
  out.commandsInProgress = out.commandsInProgress.filter(whereNot({ id: idToRemove }));
1086
1098
  out.totalCommandsCompleted++;
1087
- const time = new Date().getTime();
1088
1099
  const markForDelete = [];
1089
1100
  for (const { id, timeStamp } of out.commandsInProgress) {
1090
- if (time - timeStamp > exileTimeout) {
1101
+ if (now - timeStamp > exileTimeout) {
1091
1102
  markForDelete.push(id);
1092
1103
  }
1093
1104
  }
@@ -1100,19 +1111,19 @@ const initialState = {
1100
1111
  totalCommandsStarted: 0,
1101
1112
  totalCommandsCompleted: 0,
1102
1113
  };
1103
- const commandsReducer = createReducer(initialState, on(commandStarted, (state, { id, command }) => {
1114
+ const commandsReducer = createReducer(initialState, on(commandStarted, (state, { id, command, timeStamp }) => {
1104
1115
  return {
1105
1116
  ...state,
1106
1117
  totalCommandsStarted: state.totalCommandsStarted + 1,
1107
1118
  commandsInProgress: [
1108
1119
  ...state.commandsInProgress,
1109
- { id, command, timeStamp: new Date().getTime() },
1120
+ { id, command, timeStamp },
1110
1121
  ],
1111
1122
  };
1112
- }), on(commandCompleted, (state, { id }) => {
1123
+ }), on(commandCompleted, (state, { id, now }) => {
1113
1124
  return {
1114
1125
  ...state,
1115
- ...removeExileCommands(state, id),
1126
+ ...removeExileCommands(state, id, now),
1116
1127
  };
1117
1128
  }), on(destroyUnrealScene, setCirrusDisconnected, () => {
1118
1129
  return initialState;
@@ -1241,14 +1252,17 @@ class UnrealEffects {
1241
1252
  onDataChannelClose: this.unrealInitialConfig?.reconnect?.onDataChannelClose ??
1242
1253
  DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE,
1243
1254
  };
1244
- this.destroyConnections$ = createEffect(() => {
1255
+ this.destroyConnectionsCleanup$ = createEffect(() => {
1245
1256
  return this.actions$.pipe(ofType(destroyUnrealScene), tap(() => {
1246
1257
  this.commandsSender.destroy();
1247
1258
  this.videoService.setContainer(null);
1248
- }), map(() => destroyRemoteConnections({ reason: DisconnectReason.Destroy })));
1259
+ }));
1260
+ }, { dispatch: false });
1261
+ this.destroyConnections$ = createEffect(() => {
1262
+ return this.actions$.pipe(ofType(destroyUnrealScene), map(() => destroyRemoteConnections({ reason: DisconnectReason.Destroy })));
1249
1263
  });
1250
1264
  this.changeStreamResolution$ = createEffect(() => {
1251
- return this.actions$.pipe(ofType(changeStreamResolutionAction), filter((size) => !!(size?.width && size?.height)), withLatestFrom(this.store.select(unrealFeature.selectStreamResolution)), filter(([newSizes, savedSizes]) => !(newSizes.width === savedSizes.width &&
1265
+ return this.actions$.pipe(ofType(changeStreamResolutionAction), filter((size) => !!(size?.width && size?.height)), concatLatestFrom(() => this.store.select(unrealFeature.selectStreamResolution)), filter(([newSizes, savedSizes]) => !(newSizes.width === savedSizes.width &&
1252
1266
  newSizes.height === savedSizes.height)), map(([sizes]) => changeStreamResolutionSuccessAction({
1253
1267
  width: sizes.width,
1254
1268
  height: sizes.height,
@@ -1339,10 +1353,10 @@ class UnrealEffects {
1339
1353
  * a visible flicker (video flashing to black).
1340
1354
  */
1341
1355
  this.forceViewportNotReady$ = createEffect(() => {
1342
- return this.actions$.pipe(ofType(destroyRemoteConnections), withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), map(() => setViewportNotReady()));
1356
+ return this.actions$.pipe(ofType(destroyRemoteConnections), concatLatestFrom(() => this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), map(() => setViewportNotReady()));
1343
1357
  });
1344
1358
  this.resetCirrusOnDestroy$ = createEffect(() => {
1345
- return this.actions$.pipe(ofType(destroyRemoteConnections), withLatestFrom(this.store.select(unrealFeature.selectCirrusConnected)), filter(([, cirrusConnected]) => cirrusConnected), map(() => setCirrusDisconnected()));
1359
+ return this.actions$.pipe(ofType(destroyRemoteConnections), concatLatestFrom(() => this.store.select(unrealFeature.selectCirrusConnected)), filter(([, cirrusConnected]) => cirrusConnected), map(() => setCirrusDisconnected()));
1346
1360
  });
1347
1361
  this.destroyRemoteConnections$ = createEffect(() => {
1348
1362
  return this.actions$.pipe(ofType(destroyRemoteConnections), tap(({ reason }) => {
@@ -1354,7 +1368,7 @@ class UnrealEffects {
1354
1368
  }), filter(({ reason }) => reason === DisconnectReason.DropConnection), map(() => dropConnection()));
1355
1369
  });
1356
1370
  this.destroyConnectionAndRestart$ = createEffect(() => {
1357
- return this.actions$.pipe(ofType(setCirrusDisconnected), withLatestFrom(this.store.select(unrealFeature.selectDisconnectReason)), filter(([, reason]) => reason === DisconnectReason.DataChannelTimeout), map(() => initSignalling()));
1371
+ return this.actions$.pipe(ofType(setCirrusDisconnected), concatLatestFrom(() => this.store.select(unrealFeature.selectDisconnectReason)), filter(([, reason]) => reason === DisconnectReason.DataChannelTimeout), map(() => initSignalling()));
1358
1372
  });
1359
1373
  this.showUnrealError$ = createEffect(() => {
1360
1374
  return this.actions$.pipe(ofType(showUnrealErrorMessage), map(({ code }) => getRtcErrorMessage(code)), distinctUntilChanged(), filter(Truthy), switchMap((content) => this.dialog.open(UnrealErrorModalComponent, {
@@ -1363,7 +1377,7 @@ class UnrealEffects {
1363
1377
  }).closed), map(() => showUnrealErrorMessage({ code: null })));
1364
1378
  });
1365
1379
  this.unrealReceiverReady$ = createEffect(() => {
1366
- return this.actions$.pipe(ofType(dataChannelConnected), withLatestFrom(this.store.select(unrealFeature.selectIsReconnecting)), filter(([, isReconnecting]) => !isReconnecting), map(() => dataChannelReady()));
1380
+ return this.actions$.pipe(ofType(dataChannelConnected), concatLatestFrom(() => this.store.select(unrealFeature.selectIsReconnecting)), filter(([, isReconnecting]) => !isReconnecting), map(() => dataChannelReady()));
1367
1381
  });
1368
1382
  this.initAppByDataChannelReady$ = createEffect(() => {
1369
1383
  return this.actions$.pipe(ofType(dataChannelReady), tap(() => {
@@ -1379,8 +1393,8 @@ class UnrealEffects {
1379
1393
  }), switchMap(() =>
1380
1394
  // Wait for initial commands to be sent before sending the loopback.
1381
1395
  // commandStarted fires for each sent command; debounce ensures
1382
- // we proceed only after no more commands are started for 100 ms.
1383
- this.actions$.pipe(ofType(commandStarted), debounceTime(100), take(1), switchMap(() => this.unrealCallbackService.observeCommandResponse({ command: MetaBoxCommand.FLoopBackCommand }, (data) => this.commandsSender.sendCommandToUnreal(data))), switchMap(() => this.videoService.videoStats$), filter(({ aggregatedStats: s }) => !!s.framesPerSecond &&
1396
+ // we proceed only after no more commands are started for 50 ms.
1397
+ this.actions$.pipe(ofType(commandStarted), debounceTime(50), take(1), switchMap(() => this.unrealCallbackService.observeCommandResponse({ command: MetaBoxCommand.FLoopBackCommand }, (data) => this.commandsSender.sendCommandToUnreal(data))), switchMap(() => this.videoService.videoStats$), filter(({ aggregatedStats: s }) => !!s.framesPerSecond &&
1384
1398
  s.framesPerSecond > MINIMAL_FPS &&
1385
1399
  (s.framesDecoded ?? 0) > 15), take(1), map(() => setLoopBackCommandIsCompleted()))));
1386
1400
  });
@@ -1390,8 +1404,12 @@ class UnrealEffects {
1390
1404
  this.startStream$ = createEffect(() => {
1391
1405
  return this.actions$.pipe(ofType(startStream), map(() => initSignalling()));
1392
1406
  });
1393
- this.resetAfkOnVideoStart$ = createEffect(() => {
1394
- return this.actions$.pipe(ofType(videoStreamInitialized), filter(() => !!this.videoService.video?.paused), tap(() => Logger.info('Simulate Press Play')), map(() => resetAfk()));
1407
+ // When the video stream is initialized but autoplay didn't kick in
1408
+ // (browser blocked it; user gesture pending), the user may need extra
1409
+ // time before interacting. Refresh the AFK timer so it doesn't tick
1410
+ // toward an inactivity warning while we're waiting for permission.
1411
+ this.resetAfkOnPausedVideoInit$ = createEffect(() => {
1412
+ return this.actions$.pipe(ofType(videoStreamInitialized), filter(() => !!this.videoService.video?.paused), map(() => resetAfk()));
1395
1413
  });
1396
1414
  /**
1397
1415
  * Effect: `handleDataChannelClosed$`
@@ -1556,7 +1574,7 @@ class CommandsEffects {
1556
1574
  this.listenUnrealCallbackByInitSignalling$ = createEffect(() => {
1557
1575
  return this.actions$.pipe(ofType(initSignalling), filter(() => !this.unrealInitialConfig?.playwright), switchMap(() => this.actions$.pipe(ofType(unrealCommandCallback), map(({ json }) => 'commandCallback' in json
1558
1576
  ? json.commandCallback?.correlationId
1559
- : json?.correlationId), filter(Truthy), map((id) => commandCompleted({ id })))));
1577
+ : json?.correlationId), filter(Truthy), map((id) => commandCompleted({ id, now: Date.now() })))));
1560
1578
  });
1561
1579
  }
1562
1580
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: CommandsEffects, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -1593,7 +1611,6 @@ class AFKService extends SubService {
1593
1611
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
1594
1612
  /** Ticks every time warnTimeout changes or resetAfk action fires */
1595
1613
  this.afkResetTrigger = toSignal(merge(this.store.select(selectWarnTimeout), this.actions$.pipe(ofType(resetAfk))));
1596
- this.disconnected = toSignal(this.disconnect$);
1597
1614
  this.destroyRef.onDestroy(() => this.clearTimers());
1598
1615
  if (this.unrealConfig?.playwright) {
1599
1616
  return;
@@ -1614,12 +1631,12 @@ class AFKService extends SubService {
1614
1631
  untracked(() => this.startAfkWarningTimer());
1615
1632
  }
1616
1633
  });
1617
- effect(() => {
1618
- const disconnected = this.disconnected();
1619
- if (disconnected !== undefined) {
1620
- untracked(() => this.stop());
1621
- }
1622
- });
1634
+ // Stop the AFK timers when Cirrus drops the connection. `disconnect$`
1635
+ // emits void; the previous toSignal(disconnect$) approach never fired
1636
+ // (void → undefined → effect's `!== undefined` guard always false).
1637
+ this.disconnect$
1638
+ .pipe(takeUntil$1(this.destroy$))
1639
+ .subscribe(() => this.stop());
1623
1640
  this.actions$
1624
1641
  .pipe(ofType(resetUserInactivityTimeout), takeUntil$1(this.destroy$))
1625
1642
  .subscribe(() => this.reset());
@@ -1707,9 +1724,24 @@ class LoadingProgressService {
1707
1724
  constructor() {
1708
1725
  this.#store = inject(Store);
1709
1726
  this.loaderVisible = this.#store.selectSignal(selectShowLoader);
1710
- this.#boundedProgress$ = this.#store
1711
- .select(selectTotalProgress)
1712
- .pipe(keepMaxUntilReset(toObservable(this.loaderVisible).pipe(filter(Falsy))));
1727
+ // Visibility-gated progress source.
1728
+ //
1729
+ // When the loader is visible, subscribe to `selectTotalProgress` and run
1730
+ // a `scan` that keeps the running max — progress should only advance
1731
+ // forward within a single loading session, never regress visually.
1732
+ //
1733
+ // When the loader is hidden, switch to a constant `of(0)`. This is what
1734
+ // makes percents drop to 0 on hide / destroy. A previous attempt used
1735
+ // `keepMaxUntilReset` here; that operator could not work because
1736
+ // `selectTotalProgress` is non-completing — it keeps re-emitting the
1737
+ // last (high) value after a reset, which immediately re-fills the max.
1738
+ // `switchMap` cancels the inner subscription on every visibility change
1739
+ // so stale emissions can't leak through.
1740
+ this.#boundedProgress$ = toObservable(this.loaderVisible).pipe(switchMap((visible) => visible
1741
+ ? this.#store
1742
+ .select(selectTotalProgress)
1743
+ .pipe(scan((max, val) => Math.max(max, val), 0))
1744
+ : of(0)), shareReplay({ bufferSize: 1, refCount: true }));
1713
1745
  this.smoothPercents = toSignal(this.#boundedProgress$.pipe(floatToSmoothPercents(), distinctUntilChanged()));
1714
1746
  this.percents = toSignal(this.#boundedProgress$.pipe(floatToPercents(), distinctUntilChanged()));
1715
1747
  this.loadingStatusChanged = computed(() => ({
@@ -1718,6 +1750,19 @@ class LoadingProgressService {
1718
1750
  }), ...(ngDevMode ? [{ debugName: "loadingStatusChanged" }] : /* istanbul ignore next */ []));
1719
1751
  }
1720
1752
  #store;
1753
+ // Visibility-gated progress source.
1754
+ //
1755
+ // When the loader is visible, subscribe to `selectTotalProgress` and run
1756
+ // a `scan` that keeps the running max — progress should only advance
1757
+ // forward within a single loading session, never regress visually.
1758
+ //
1759
+ // When the loader is hidden, switch to a constant `of(0)`. This is what
1760
+ // makes percents drop to 0 on hide / destroy. A previous attempt used
1761
+ // `keepMaxUntilReset` here; that operator could not work because
1762
+ // `selectTotalProgress` is non-completing — it keeps re-emitting the
1763
+ // last (high) value after a reset, which immediately re-fills the max.
1764
+ // `switchMap` cancels the inner subscription on every visibility change
1765
+ // so stale emissions can't leak through.
1721
1766
  #boundedProgress$;
1722
1767
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: LoadingProgressService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1723
1768
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: LoadingProgressService }); }
@@ -1740,12 +1785,8 @@ class FreezeFrameService extends SubService {
1740
1785
  if (this.unrealConfig?.playwright) {
1741
1786
  return;
1742
1787
  }
1743
- this.store
1744
- .select(unrealFeature.selectViewportReady)
1745
- .pipe(filter(Truthy), takeUntil(this.destroy$))
1746
- .subscribe(() => this.invalidate());
1747
- this.actions$
1748
- .pipe(ofType(videoStreamInitialized), takeUntil(this.destroy$))
1788
+ merge(this.store.select(unrealFeature.selectViewportReady).pipe(filter(Truthy)), this.actions$.pipe(ofType(videoStreamInitialized)))
1789
+ .pipe(takeUntil(this.destroy$))
1749
1790
  .subscribe(() => this.invalidate());
1750
1791
  }
1751
1792
  setData(view) {
@@ -2099,7 +2140,9 @@ class VideoService extends SubService {
2099
2140
  video.disablePictureInPicture = true;
2100
2141
  this.video = video;
2101
2142
  this.container?.appendChild(this.video);
2102
- this.video.addEventListener('play', () => this.changeStatusMainVideoOnScene(true));
2143
+ fromEvent(this.video, 'play')
2144
+ .pipe(takeUntil$1(merge(this.disconnect$, this.destroy$)))
2145
+ .subscribe(() => this.changeStatusMainVideoOnScene(true));
2103
2146
  fromEvent(this.video, 'loadedmetadata')
2104
2147
  .pipe(take(1), filter((data) => this.video === data.target), tapLog('VideoService loadedmetadata:'), filter(() => !!this.video))
2105
2148
  .subscribe(() => this.store.dispatch(videoStreamInitialized()));
@@ -2127,6 +2170,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
2127
2170
  class CommandTelemetryService {
2128
2171
  constructor() {
2129
2172
  this.actions$ = inject(Actions);
2173
+ this.destroyRef = inject(DestroyRef);
2174
+ this.destroy$ = new Subject();
2130
2175
  this.appId = 'metabox-telemetry';
2131
2176
  this.commandsSent = {};
2132
2177
  this.exileTimeout = 60000;
@@ -2137,6 +2182,10 @@ class CommandTelemetryService {
2137
2182
  this.unrealConfig = inject(UNREAL_CONFIG, {
2138
2183
  optional: true,
2139
2184
  });
2185
+ this.destroyRef.onDestroy(() => {
2186
+ this.destroy$.next();
2187
+ this.destroy$.complete();
2188
+ });
2140
2189
  this.init();
2141
2190
  }
2142
2191
  init() {
@@ -2149,7 +2198,9 @@ class CommandTelemetryService {
2149
2198
  this.userId = localStorage.getItem(this.appId) || this.uuid;
2150
2199
  sessionStorage.setItem(this.appId, this.sessionId);
2151
2200
  localStorage.setItem(this.appId, this.userId);
2152
- timer(0, this.pollingTime).subscribe(() => {
2201
+ timer(0, this.pollingTime)
2202
+ .pipe(takeUntil(this.destroy$))
2203
+ .subscribe(() => {
2153
2204
  this.send(this.payloads);
2154
2205
  });
2155
2206
  }
@@ -2271,7 +2322,7 @@ class CommandTelemetryService {
2271
2322
  .pipe(ofType(unrealCommandCallback), filter(({ json }) => {
2272
2323
  const callback = 'commandCallback' in json ? json.commandCallback : json;
2273
2324
  return Truthy(callback?.trackingId);
2274
- }))
2325
+ }), takeUntil(this.destroy$))
2275
2326
  .subscribe(({ json }) => {
2276
2327
  const callback = 'commandCallback' in json ? json.commandCallback : json;
2277
2328
  const timeStampsRaw = 'timeStamp' in json ? json?.timeStamp : undefined;
@@ -2311,7 +2362,7 @@ class CommandTelemetryService {
2311
2362
  * Starts tracking the time for the command.
2312
2363
  * Finally, calls the provided function with the modified data object.
2313
2364
  *
2314
- * @param {IToBeDecorated} funcToDecorate - The function to be decorated with telemetry tracking.
2365
+ * @param {ToBeDecorated} funcToDecorate - The function to be decorated with telemetry tracking.
2315
2366
  * @param {MetaBoxCommandPacket} data - The data object to be passed to the function. It will be augmented with a unique tracking ID.
2316
2367
  */
2317
2368
  wrapper(funcToDecorate, data) {
@@ -2547,14 +2598,15 @@ function OrchestrationErrorHandler(msg) {
2547
2598
  const errorInfo = Object.values(ORCHESTRATION_ERROR_CODES).find(({ code }) => code === msg.payload.code);
2548
2599
  this.store.dispatch(setOrchestrationMessage({ message: errorInfo?.description }));
2549
2600
  this.orchestrationRetrySub?.unsubscribe();
2550
- this.orchestrationRetrySub = timer(POLLING_TIME).subscribe(() => this.sendRequestStream());
2601
+ this.orchestrationRetrySub = timer(POLLING_TIME)
2602
+ .pipe(takeUntil(this.destroy$))
2603
+ .subscribe(() => this.sendRequestStream());
2551
2604
  }
2552
2605
  }
2553
2606
 
2554
2607
  class SignallingService extends SubService {
2555
2608
  constructor() {
2556
2609
  super();
2557
- this.action$ = inject(Actions);
2558
2610
  this.httpClient = inject(HttpClient);
2559
2611
  this.regionsPingService = inject(RegionsPingService);
2560
2612
  this.commandTelemetryService = inject(CommandTelemetryService);
@@ -2567,7 +2619,7 @@ class SignallingService extends SubService {
2567
2619
  this.onConfig$ = new Subject();
2568
2620
  this.onWebRtcIce$ = new Subject();
2569
2621
  this.onWebRtcAnswer$ = new Subject();
2570
- this.abort$ = this.action$.pipe(ofType(abortEstablishingConnection), tap(() => this.close({
2622
+ this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection), tap(() => this.close({
2571
2623
  code: 1000,
2572
2624
  reason: DisconnectReason.AbortEstablishingConnection,
2573
2625
  })));
@@ -2579,8 +2631,9 @@ class SignallingService extends SubService {
2579
2631
  return;
2580
2632
  }
2581
2633
  this.setHandlersFromStream();
2582
- this.action$
2583
- .pipe(ofType(dataChannelReady), takeUntil(this.abort$))
2634
+ const stop$ = merge(this.abort$, this.destroy$);
2635
+ this.actions$
2636
+ .pipe(ofType(dataChannelReady), takeUntil(stop$))
2584
2637
  .subscribe(() => {
2585
2638
  this.send({
2586
2639
  type: 'p2pEstablished',
@@ -2588,10 +2641,10 @@ class SignallingService extends SubService {
2588
2641
  sessionId: generateUuid(),
2589
2642
  });
2590
2643
  });
2591
- this.action$
2644
+ this.actions$
2592
2645
  .pipe(ofType(dataChannelReady), switchMap(() => this.store
2593
2646
  .select(unrealFeature.selectViewportReady)
2594
- .pipe(filter(Truthy), take(1))), takeUntil(this.abort$))
2647
+ .pipe(filter(Truthy), take(1))), takeUntil(stop$))
2595
2648
  .subscribe(() => {
2596
2649
  this.send({
2597
2650
  type: 'streamPreparedForUserInteraction',
@@ -2608,11 +2661,12 @@ class SignallingService extends SubService {
2608
2661
  return EMPTY;
2609
2662
  }
2610
2663
  Logger.log('CONNECT TO SIGNALLING');
2611
- // Preparation for WebSocket Orchestration
2612
- this.correlationId = generateUuid();
2613
- this.commandTelemetryService.reset();
2614
- this.startEstablishingConnection();
2615
2664
  return defer(() => {
2665
+ // Preparation for WebSocket Orchestration. Inside defer so these
2666
+ // side effects only fire once the caller actually subscribes.
2667
+ this.correlationId = generateUuid();
2668
+ this.commandTelemetryService.reset();
2669
+ this.startEstablishingConnection();
2616
2670
  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$));
2617
2671
  }).pipe(
2618
2672
  // Retry the whole attempt on error, advancing to the next URL each time.
@@ -3633,7 +3687,12 @@ class FileReceiverService extends SubService {
3633
3687
  this.reset();
3634
3688
  }
3635
3689
  }
3690
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FileReceiverService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
3691
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FileReceiverService }); }
3636
3692
  }
3693
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FileReceiverService, decorators: [{
3694
+ type: Injectable
3695
+ }], ctorParameters: () => [] });
3637
3696
 
3638
3697
  const filteredLogs = [
3639
3698
  {
@@ -3641,9 +3700,6 @@ const filteredLogs = [
3641
3700
  },
3642
3701
  ];
3643
3702
  class AggregatorService extends SubService {
3644
- /**
3645
- * Never called for now
3646
- */
3647
3703
  constructor() {
3648
3704
  super();
3649
3705
  this.selectStreamConfig = this.store.selectSignal(selectStreamConfig);
@@ -3682,11 +3738,9 @@ class AggregatorService extends SubService {
3682
3738
  .pipe(filter(Truthy));
3683
3739
  merge(unlockViewport$, this.disconnect$)
3684
3740
  .pipe(takeUntil(this.destroy$))
3685
- .subscribe(() => {
3686
- this.removeLoadScreen();
3687
- });
3741
+ .subscribe(() => this.removeLoadScreen());
3688
3742
  if (!this.selectStreamConfig()?.autoStart ||
3689
- location.href.match(/^http:/gi)) {
3743
+ location.protocol === 'http:') {
3690
3744
  this.removeLoadScreen();
3691
3745
  }
3692
3746
  InputOptions.controlScheme = ControlScheme.HoveringMouse;
@@ -3933,14 +3987,27 @@ class UnrealCommunicatorService {
3933
3987
  this.commandTelemetryService = inject(CommandTelemetryService);
3934
3988
  this.webRtcPlayerService = inject(WebRtcPlayerService);
3935
3989
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
3990
+ this.destroyRef = inject(DestroyRef);
3936
3991
  this.destroy$ = new Subject();
3937
3992
  this.cirrusConnected = this.store.selectSignal(unrealFeature.selectCirrusConnected);
3938
3993
  this.afkIgnoredCommands = new Set(['executeConsoleCommand']);
3939
3994
  this.skipAfkReset = false;
3995
+ // Throttle: input events fire 60-200x/sec, but AFK timeouts are measured in
3996
+ // seconds. Leading-edge throttle dispatches once, then suppresses for 1s.
3997
+ this.resetAfk$ = new Subject();
3998
+ this.destroyRef.onDestroy(() => {
3999
+ this.destroy$.next();
4000
+ this.destroy$.complete();
4001
+ });
4002
+ this.resetAfk$
4003
+ .pipe(throttleTime(1000), takeUntil$1(this.destroy$))
4004
+ .subscribe(() => this.store.dispatch(resetAfk()));
3940
4005
  this.init();
3941
4006
  }
3942
4007
  init() {
3943
- this.isPlaywrightMode();
4008
+ if (this.isPlaywrightMode()) {
4009
+ return;
4010
+ }
3944
4011
  this.emitUIInteraction = this.commandTelemetryService.decorate(this.emitUIInteraction.bind(this));
3945
4012
  // Listen to the dataChannelConnected ACTION (point-in-time event) instead
3946
4013
  // of the store selector. The selector reads the current state and can fire on
@@ -3960,7 +4027,9 @@ class UnrealCommunicatorService {
3960
4027
  this.destroy$ = new Subject();
3961
4028
  }
3962
4029
  sendCommandToUnreal(data) {
3963
- this.isPlaywrightMode();
4030
+ if (this.isPlaywrightMode()) {
4031
+ return;
4032
+ }
3964
4033
  if (this.webRtcPlayerService.canSend()) {
3965
4034
  const uniqId = data?.correlationId ?? generateUuid();
3966
4035
  const sendData = { ...data, correlationId: uniqId };
@@ -3970,7 +4039,11 @@ class UnrealCommunicatorService {
3970
4039
  }
3971
4040
  this.emitUIInteraction(sendData);
3972
4041
  this.skipAfkReset = false;
3973
- this.store.dispatch(commandStarted({ id: uniqId, command: data.command }));
4042
+ this.store.dispatch(commandStarted({
4043
+ id: uniqId,
4044
+ command: data.command,
4045
+ timeStamp: Date.now(),
4046
+ }));
3974
4047
  }
3975
4048
  }
3976
4049
  emitUIInteraction(descriptor) {
@@ -3991,10 +4064,12 @@ class UnrealCommunicatorService {
3991
4064
  this.emitDescriptor(MessageType.Command, descriptor);
3992
4065
  }
3993
4066
  sendInputData(data) {
3994
- this.isPlaywrightMode();
4067
+ if (this.isPlaywrightMode()) {
4068
+ return;
4069
+ }
3995
4070
  try {
3996
4071
  if (!this.skipAfkReset) {
3997
- this.store.dispatch(resetAfk());
4072
+ this.resetAfk$.next();
3998
4073
  }
3999
4074
  this.webRtcPlayerService.send(data);
4000
4075
  }
@@ -4005,9 +4080,7 @@ class UnrealCommunicatorService {
4005
4080
  }
4006
4081
  }
4007
4082
  isPlaywrightMode() {
4008
- if (this.unrealConfig?.playwright) {
4009
- return;
4010
- }
4083
+ return !!this.unrealConfig?.playwright;
4011
4084
  }
4012
4085
  emitDescriptor(messageType, descriptor) {
4013
4086
  // Convert the descriptor object into a JSON string.
@@ -4093,7 +4166,7 @@ class InputService extends SubService {
4093
4166
  }
4094
4167
  const visibilityHiddenTrigger$ = fromEvent(document, 'visibilitychange').pipe(map$1(() => document.visibilityState === 'hidden'), filter(Truthy));
4095
4168
  const visibilityVisibleTrigger$ = fromEvent(document, 'visibilitychange').pipe(map$1(() => document.visibilityState === 'visible'), filter(Truthy), startWith(true));
4096
- this.deactivatedVideoTrigger$ = merge(this.disconnect$, this.reInit$, visibilityHiddenTrigger$);
4169
+ this.deactivatedVideoTrigger$ = merge(this.disconnect$, this.reInit$, visibilityHiddenTrigger$, this.destroy$);
4097
4170
  merge(visibilityVisibleTrigger$, this.store.select(unrealFeature.selectViewportReady).pipe(filter(Truthy)))
4098
4171
  .pipe(filter(() => !!this.video), takeUntil$1(this.destroy$))
4099
4172
  .subscribe(() => this.setup());
@@ -4526,7 +4599,12 @@ class InputService extends SubService {
4526
4599
  this.sendInputData(data.buffer);
4527
4600
  });
4528
4601
  }
4602
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: InputService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4603
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: InputService }); }
4529
4604
  }
4605
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: InputService, decorators: [{
4606
+ type: Injectable
4607
+ }], ctorParameters: () => [] });
4530
4608
 
4531
4609
  class RegionsPingService {
4532
4610
  constructor() {
@@ -4545,56 +4623,50 @@ class RegionsPingService {
4545
4623
  // flow (start / providers-fetched / resolved). The final
4546
4624
  // `regionResolved` forwards the winner's full ping-sample array
4547
4625
  // straight from `PingResult.result` — no aggregation.
4548
- this.store.dispatch(regionPingFetchStart());
4549
- 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({
4550
- region: data?.region_code ?? '',
4551
- pingsMs: data?.result ?? [],
4552
- }))), map$1((data) => data?.region_code));
4626
+ return defer(() => {
4627
+ this.store.dispatch(regionPingFetchStart());
4628
+ 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({
4629
+ region: data?.region_code ?? '',
4630
+ pingsMs: data?.result ?? [],
4631
+ }))), map$1((data) => data?.region_code));
4632
+ });
4553
4633
  }
4554
4634
  getPingResult(providers) {
4555
- const regions = providers.regions;
4556
- this.config.ping_timeout = providers.timeout || this.config.ping_timeout;
4557
- return from(this.startPinging(regions));
4558
- }
4559
- getProviders(regionList = this.unrealInitialConfig?.regionsPingUrl || '') {
4560
- return this.httpClient.get(regionList);
4561
- }
4562
- startPinging(endpoints) {
4563
- // eslint-disable-next-line no-async-promise-executor
4564
- return new Promise(async (resolve) => {
4565
- const pingResults = [];
4566
- const semaphore = new Semaphore(10);
4567
- const tasks = endpoints.map(async ({ region_code, url, timeout }) => {
4568
- await semaphore.acquire();
4569
- try {
4570
- const result = await this.checkLatency(url, timeout);
4571
- const data = { region_code, result };
4572
- pingResults.push({ region_code, result });
4573
- resolve(data);
4574
- }
4575
- catch {
4576
- pingResults.push({ region_code: undefined, result: [] });
4577
- }
4578
- finally {
4579
- semaphore.release();
4580
- }
4581
- const okValues = pingResults.filter((pingResult) => pingResult.result.length > 1);
4582
- if (okValues.length) {
4583
- return okValues[0];
4584
- }
4585
- return okValues;
4586
- });
4587
- await Promise.all(tasks);
4588
- resolve(null);
4635
+ const defaultTimeout = providers.timeout ?? this.config.ping_timeout;
4636
+ return from(this.startPinging(providers.regions, defaultTimeout));
4637
+ }
4638
+ getProviders(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4639
+ return this.httpClient.get(regionListUrl);
4640
+ }
4641
+ async startPinging(endpoints, defaultTimeout) {
4642
+ const semaphore = new Semaphore(this.config.max_parallel_requests);
4643
+ const tasks = endpoints.map(async ({ region_code, url, timeout }) => {
4644
+ await semaphore.acquire();
4645
+ try {
4646
+ const result = await this.checkLatency(url, timeout ?? defaultTimeout);
4647
+ if (result.length < 2)
4648
+ throw new Error('insufficient pings');
4649
+ return { region_code, result };
4650
+ }
4651
+ finally {
4652
+ semaphore.release();
4653
+ }
4589
4654
  });
4655
+ try {
4656
+ return await Promise.any(tasks);
4657
+ }
4658
+ catch {
4659
+ return null;
4660
+ }
4590
4661
  }
4591
- async checkLatency(endpoint, timeout = this.config.ping_timeout) {
4662
+ async checkLatency(endpoint, timeout) {
4592
4663
  const pingResults = [];
4664
+ const sep = endpoint.includes('?') ? '&' : '?';
4593
4665
  for (let i = 0; i < this.config.ping_count; i++) {
4594
4666
  const startTime = performance.now();
4595
4667
  try {
4596
4668
  await this.fetchWithTimeout(this.config.bust_cache
4597
- ? `${endpoint}?&cache_buster=${performance.now()}`
4669
+ ? `${endpoint}${sep}cache_buster=${performance.now()}`
4598
4670
  : endpoint, {
4599
4671
  method: 'GET',
4600
4672
  mode: 'no-cors',
@@ -4603,11 +4675,11 @@ class RegionsPingService {
4603
4675
  Pragma: 'no-cache',
4604
4676
  Expires: '0',
4605
4677
  },
4606
- credentials: 'omit', // Optional: prevents sending or receiving cookies, which can affect caching.
4678
+ credentials: 'omit',
4607
4679
  }, timeout);
4608
4680
  }
4609
4681
  catch {
4610
- //Do not remove this block, else finally will not work!
4682
+ // intentional: finally pushes the elapsed time as the latency sample
4611
4683
  }
4612
4684
  finally {
4613
4685
  const latency = Math.round(performance.now() - startTime);
@@ -4637,10 +4709,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
4637
4709
  type: Injectable
4638
4710
  }] });
4639
4711
 
4640
- class StreamStatusTelemetryService {
4712
+ class StreamStatusTelemetryService extends SubService {
4641
4713
  constructor() {
4642
- this.store = inject(Store);
4643
- this.actions$ = inject(Actions);
4714
+ super();
4644
4715
  this.videoService = inject(VideoService);
4645
4716
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4646
4717
  this.init();
@@ -4670,8 +4741,8 @@ class StreamStatusTelemetryService {
4670
4741
  signalingReadyTime,
4671
4742
  viewPortReadyTime,
4672
4743
  })));
4673
- }))
4674
- .subscribe((data) => this.trackEventToMixPanel(data));
4744
+ }), takeUntil$1(this.destroy$))
4745
+ .subscribe((data) => this.store.dispatch(trackMixpanelEvent({ event: 'streamStatus', data })));
4675
4746
  }
4676
4747
  /**
4677
4748
  * Emits `undefined` until `readyState$` first emits a truthy value,
@@ -4702,9 +4773,6 @@ class StreamStatusTelemetryService {
4702
4773
  VideoEncoderQP,
4703
4774
  };
4704
4775
  }
4705
- trackEventToMixPanel(data) {
4706
- this.store.dispatch(trackMixpanelEvent({ event: 'streamStatus', data }));
4707
- }
4708
4776
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: StreamStatusTelemetryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4709
4777
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: StreamStatusTelemetryService }); }
4710
4778
  }
@@ -4726,6 +4794,8 @@ class StreamStatusTelemetryService2 {
4726
4794
  this.videoService = inject(VideoService);
4727
4795
  this.signallingService = inject(SignallingService);
4728
4796
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4797
+ this.destroyRef = inject(DestroyRef);
4798
+ this.destroy$ = new Subject();
4729
4799
  /**
4730
4800
  * Per-browser anonymous user id. Read from `localStorage` on first access;
4731
4801
  * if missing, a new UUID is generated and persisted. Included as
@@ -4941,6 +5011,13 @@ class StreamStatusTelemetryService2 {
4941
5011
  ],
4942
5012
  },
4943
5013
  ];
5014
+ this.destroyRef.onDestroy(() => {
5015
+ // End any active session BEFORE completing destroy$, so the
5016
+ // session-scoped subscriptions in `this.session.sub` are released.
5017
+ this.endSession('destroy');
5018
+ this.destroy$.next();
5019
+ this.destroy$.complete();
5020
+ });
4944
5021
  if (this.unrealConfig?.playwright ||
4945
5022
  !this.unrealConfig?.streamTelemetryV2) {
4946
5023
  return;
@@ -4965,16 +5042,18 @@ class StreamStatusTelemetryService2 {
4965
5042
  init() {
4966
5043
  // Start a new session on every initSignalling dispatch.
4967
5044
  this.actions$
4968
- .pipe(ofType(initSignalling))
5045
+ .pipe(ofType(initSignalling), takeUntil$1(this.destroy$))
4969
5046
  .subscribe(() => this.startSession());
4970
5047
  // End the current session when Cirrus drops (true → false transition).
4971
5048
  this.store
4972
5049
  .select(unrealFeature.selectCirrusConnected)
4973
- .pipe(pairwise(), filter$1(([prev, curr]) => prev && !curr))
5050
+ .pipe(pairwise(), filter$1(([prev, curr]) => prev && !curr), takeUntil$1(this.destroy$))
4974
5051
  .subscribe(() => this.endSession('disconnected'));
4975
5052
  // Close the session when the tab is hidden / being evicted from cache.
4976
5053
  // `pagehide` fires in more cases than `beforeunload` (covers iOS Safari, bfcache).
4977
- fromEvent(window, 'pagehide').subscribe(() => this.endSession('pagehide'));
5054
+ fromEvent(window, 'pagehide')
5055
+ .pipe(takeUntil$1(this.destroy$))
5056
+ .subscribe(() => this.endSession('pagehide'));
4978
5057
  }
4979
5058
  startSession() {
4980
5059
  // If a previous session is still active, close it first.
@@ -5434,17 +5513,19 @@ class FileHandlerService {
5434
5513
  observeFileResponse(mimetype, data, sender, timeOut = 60000) {
5435
5514
  const correlationId = generateUuid();
5436
5515
  const out = { ...data, correlationId };
5437
- const observable = this.fileService.fileComplete$.pipe(switchMap((data) => {
5438
- if (data.valid && this.fileHandlers[mimetype]) {
5439
- return this.fileHandlers[mimetype](data, correlationId).pipe(filter(Truthy));
5440
- }
5441
- return of(null);
5442
- }), timeout(timeOut), catchError((e) => {
5443
- Logger.error(e);
5444
- return of(null);
5445
- }), take(1));
5446
- setTimeout(() => sender(out), 0);
5447
- return observable;
5516
+ return defer(() => {
5517
+ const observable = this.fileService.fileComplete$.pipe(switchMap((data) => {
5518
+ if (data.valid && this.fileHandlers[mimetype]) {
5519
+ return this.fileHandlers[mimetype](data, correlationId).pipe(filter(Truthy));
5520
+ }
5521
+ return of(null);
5522
+ }), timeout(timeOut), catchError((e) => {
5523
+ Logger.error(e);
5524
+ return of(null);
5525
+ }), take(1));
5526
+ setTimeout(() => sender(out), 0);
5527
+ return observable;
5528
+ });
5448
5529
  }
5449
5530
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FileHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5450
5531
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FileHandlerService }); }
@@ -5454,25 +5535,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
5454
5535
  }] });
5455
5536
 
5456
5537
  class AnalyticsService extends SubService {
5457
- /** Signals monitorMouse inner subscriptions to stop (viewport lost / new session). */
5458
5538
  #stopMouse$ = new Subject();
5459
- sendEventToMixPanel(name, params = {}) {
5460
- Logger.log('Analytics Event:', name, params);
5461
- this.store.dispatch(saveAnalyticsEvent({ event: { name, params } }));
5462
- }
5463
5539
  constructor() {
5464
5540
  super();
5465
- const abortSignal$ = this.store
5466
- .select(unrealFeature.selectViewportReady)
5467
- .pipe(filter$1(Falsy), map(() => void 0));
5468
5541
  this.store
5469
5542
  .select(unrealFeature.selectViewportReady)
5470
- .pipe(distinctUntilChanged(), filter$1(Truthy), takeUntil$1(this.destroy$))
5471
- .subscribe(() => this.monitorMouse(abortSignal$));
5543
+ .pipe(distinctUntilChanged(), takeUntil$1(this.destroy$))
5544
+ .subscribe((ready) => {
5545
+ this.#stopMouse$.next();
5546
+ if (ready) {
5547
+ this.monitorMouse();
5548
+ }
5549
+ });
5472
5550
  }
5473
- monitorMouse(abortSignal$) {
5474
- this.#stopMouse$.next();
5475
- const stop$ = merge(this.#stopMouse$.asObservable(), abortSignal$);
5551
+ monitorMouse() {
5552
+ const stop$ = this.#stopMouse$.asObservable();
5476
5553
  const element = document.getElementById('streamingVideo');
5477
5554
  if (!element) {
5478
5555
  return;
@@ -5537,6 +5614,10 @@ class AnalyticsService extends SubService {
5537
5614
  });
5538
5615
  });
5539
5616
  }
5617
+ sendEventToMixPanel(name, params = {}) {
5618
+ Logger.log('Analytics Event:', name, params);
5619
+ this.store.dispatch(saveAnalyticsEvent({ event: { name, params } }));
5620
+ }
5540
5621
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AnalyticsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5541
5622
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AnalyticsService }); }
5542
5623
  }
@@ -5617,10 +5698,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
5617
5698
  * the existing `CommandsEffects.setMaxFps$` effect that sends the
5618
5699
  * `t.MaxFPS <value>` console command to Unreal Engine.
5619
5700
  */
5620
- class FpsMonitorService {
5701
+ class FpsMonitorService extends SubService {
5621
5702
  constructor() {
5703
+ super();
5622
5704
  this.videoService = inject(VideoService);
5623
- this.store = inject(Store);
5624
5705
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
5625
5706
  /** Discrete FPS targets the controller can switch between (ascending order). Last 0 is an unlimited framerate indicator*/
5626
5707
  this.FPS_STEPS = [30, 40, 50, 0];
@@ -5659,9 +5740,6 @@ class FpsMonitorService {
5659
5740
  this.samples = [];
5660
5741
  this.lastDecisionTime = Date.now();
5661
5742
  this.lastUpgrade = 'hold';
5662
- this.disconnect$ = this.store
5663
- .select(unrealFeature.selectCirrusConnected)
5664
- .pipe(skip(1), filter(Falsy));
5665
5743
  this.init();
5666
5744
  }
5667
5745
  init() {
@@ -5671,17 +5749,19 @@ class FpsMonitorService {
5671
5749
  // Reset mutable state and re-subscribe when a new session starts
5672
5750
  this.store
5673
5751
  .select(unrealFeature.selectViewportReady)
5674
- .pipe(filter(Boolean))
5752
+ .pipe(filter(Boolean), takeUntil(this.destroy$))
5675
5753
  .subscribe(() => this.startSession());
5676
5754
  // Reset mutable state when session ends
5677
- this.disconnect$.subscribe(() => this.resetSession());
5755
+ this.disconnect$
5756
+ .pipe(takeUntil(this.destroy$))
5757
+ .subscribe(() => this.resetSession());
5678
5758
  }
5679
5759
  startSession() {
5680
5760
  // Tear down previous stats subscription if any
5681
5761
  this.session$.next();
5682
5762
  this.resetMutableState();
5683
5763
  this.videoService.videoStats$
5684
- .pipe(withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), takeUntil(this.session$))
5764
+ .pipe(withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), takeUntil(merge(this.session$, this.destroy$)))
5685
5765
  .subscribe(([stats]) => this.processTick(stats));
5686
5766
  }
5687
5767
  resetSession() {
@@ -5967,14 +6047,16 @@ class UnrealCallbackService {
5967
6047
  */
5968
6048
  observeCommandResponse(data, sender, timeOut = 60000, dispatchOnTimeout = true) {
5969
6049
  const correlationId = generateUuid();
5970
- const observable = this.actions$.pipe(ofType(unrealCommandCallback), map$1(({ json }) => 'commandCallback' in json ? json.commandCallback : json), filter((json) => json.correlationId === correlationId), timeout(timeOut), catchError(() => dispatchOnTimeout
5971
- ? of({ command: 'timeout', correlationId: 'timeout' })
5972
- : of(null)), take(1), tapLog('Command Response Observer'), filter(Truthy), map$1((data) => ({
5973
- command: data.command,
5974
- payload: 'payload' in data ? data.payload : null,
5975
- })));
5976
- setTimeout(() => sender({ ...data, correlationId }), 0);
5977
- return observable;
6050
+ return defer(() => {
6051
+ const observable = this.actions$.pipe(ofType(unrealCommandCallback), map$1(({ json }) => 'commandCallback' in json ? json.commandCallback : json), filter((json) => json.correlationId === correlationId), timeout(timeOut), catchError(() => dispatchOnTimeout
6052
+ ? of({ command: 'timeout', correlationId: 'timeout' })
6053
+ : of(null)), take(1), tapLog('Command Response Observer'), filter(Truthy), map$1((data) => ({
6054
+ command: data.command,
6055
+ payload: 'payload' in data ? data.payload : null,
6056
+ })));
6057
+ setTimeout(() => sender({ ...data, correlationId }), 0);
6058
+ return observable;
6059
+ });
5978
6060
  }
5979
6061
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: UnrealCallbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5980
6062
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: UnrealCallbackService }); }
@@ -6290,7 +6372,7 @@ class ResumeStreamButtonComponent {
6290
6372
  constructor() {
6291
6373
  this.#store = inject(Store);
6292
6374
  this.streamConfig = this.#store.selectSignal(selectStreamConfig);
6293
- this.isSecondStart = signal(!!(this.streamConfig()?.autoStart && location.href.match(/^https/gi)), ...(ngDevMode ? [{ debugName: "isSecondStart" }] : /* istanbul ignore next */ []));
6375
+ this.isSecondStart = signal(this.streamConfig()?.autoStart && location.protocol === 'https:', ...(ngDevMode ? [{ debugName: "isSecondStart" }] : /* istanbul ignore next */ []));
6294
6376
  this.isLoaderScreenVisible = this.#store.selectSignal(selectIsLoaderScreenVisible);
6295
6377
  this.showReconnectPopup = this.#store.selectSignal(selectShowReconnectPopup);
6296
6378
  this.isExistMatchUrls = this.#store.selectSignal(selectIsExistMatchUrls);
@@ -6650,6 +6732,14 @@ class LowBandwidthDetectorComponent {
6650
6732
  this.videoService = inject(VideoService);
6651
6733
  this.isDevMode = inject(DevModeService).isDevMode;
6652
6734
  this.viewportReady$ = this.#store.select(unrealFeature.selectViewportReady);
6735
+ // TODO: `isLowBandwidth$` is currently stubbed to `of(true)`, which
6736
+ // makes the entire monitoring chain in `ngOnInit` unreachable
6737
+ // (`monitoringTrigger$` always emits `false`, `canSwitchToLowBand$`
6738
+ // never passes its `filter(Truthy)`, and `trySetLowBandwidth` is
6739
+ // never invoked). Only the initial `toggleIndicator(true)` fires
6740
+ // once on mount. Wire this to a real reactive source (e.g. derived
6741
+ // from bitrate/FPS thresholds in `aggregatedStats$`) before
6742
+ // re-enabling the LBM feature, and remove this comment.
6653
6743
  this.isLowBandwidth$ = of(true);
6654
6744
  this.lowBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Truthy));
6655
6745
  this.highBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Falsy));