@3dsource/angular-unreal-module 0.0.154 → 0.0.156-dev.0

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.
package/README.md CHANGED
@@ -20,26 +20,29 @@ This package provides:
20
20
 
21
21
  ### Prerequisites
22
22
 
23
- - Angular 18+
24
- - NgRx store and effects (v18+)
25
- - Angular CDK (v18+) — used for dialog overlays
23
+ - Angular `>=19.0.0 <23.0.0`
24
+ - NgRx store and effects `>=19.0.0 <23.0.0`
25
+ - Angular CDK `>=19.0.0 <23.0.0` — used for dialog overlays
26
+ - RxJS `>=7.8.0 <8.0.0`
26
27
  - `provideHttpClient()` — required by internal services (telemetry, signalling, regions ping, error reporting)
27
28
 
28
29
  ### Peer Dependencies
29
30
 
30
- This library requires the following peer dependencies (match or exceed versions):
31
+ This library requires the following peer dependencies:
31
32
 
32
33
  ```json
33
34
  {
34
35
  "@3dsource/source-ui-native": ">=1.0.9",
35
36
  "@3dsource/types-unreal": ">=0.0.7",
36
37
  "@3dsource/utils": ">=1.0.21",
37
- "@angular/cdk": ">=19.0.0",
38
- "@angular/common": ">=19.0.0",
39
- "@angular/core": ">=19.0.0",
40
- "@angular/forms": ">=19.0.0",
41
- "@ngrx/effects": ">=19.0.0",
42
- "@ngrx/store": ">=19.0.0"
38
+ "@angular/cdk": ">=19.0.0 <23.0.0",
39
+ "@angular/common": ">=19.0.0 <23.0.0",
40
+ "@angular/core": ">=19.0.0 <23.0.0",
41
+ "@angular/forms": ">=19.0.0 <23.0.0",
42
+ "@angular/platform-browser": ">=19.0.0 <23.0.0",
43
+ "@ngrx/effects": ">=19.0.0 <23.0.0",
44
+ "@ngrx/store": ">=19.0.0 <23.0.0",
45
+ "rxjs": ">=7.8.0 <8.0.0"
43
46
  }
44
47
  ```
45
48
 
@@ -53,7 +56,7 @@ npm i @3dsource/angular-unreal-module
53
56
 
54
57
  The API is fully standalone (no NgModule). Use providers and components as shown below.
55
58
 
56
- ### 1) Provide the module services and store slice
59
+ ### 1) Provide the state and config at the application root
57
60
 
58
61
  Add providers in your application bootstrap (e.g., `app.config.ts`):
59
62
 
@@ -64,8 +67,7 @@ import { ApplicationConfig } from '@angular/core';
64
67
  import { provideRouter } from '@angular/router';
65
68
  import { provideHttpClient } from '@angular/common/http';
66
69
  import { provideStore } from '@ngrx/store';
67
- import { provideEffects } from '@ngrx/effects';
68
- import { provideUnrealModule, UNREAL_CONFIG } from '@3dsource/angular-unreal-module';
70
+ import { provideUnrealState, UNREAL_CONFIG } from '@3dsource/angular-unreal-module';
69
71
 
70
72
  export const appConfig: ApplicationConfig = {
71
73
  providers: [
@@ -74,7 +76,7 @@ export const appConfig: ApplicationConfig = {
74
76
 
75
77
  // Root NgRx (if not already added in your app)
76
78
  provideStore(),
77
- provideEffects(),
79
+ provideUnrealState(),
78
80
 
79
81
  // Required: Unreal initial configuration
80
82
  {
@@ -85,14 +87,12 @@ export const appConfig: ApplicationConfig = {
85
87
  regionsPingUrl: '', // URL prefix for regions latency ping
86
88
  screenLockerContainerId: '', // DOM container id for screen locker overlay
87
89
  dataChannelConnectionTimeout: 8000, // Timeout in ms for data channel connection (default: 8000)
90
+ streamTelemetryV2Url: '', // Endpoint for stream-status telemetry
91
+ fpsMonitor: false, // Enable FPS monitoring
92
+ autoHighResolution: false, // Raise resolution after the scene becomes idle
88
93
  playwright: false, // Mirrors the provider flag for services/effects
89
94
  },
90
95
  },
91
-
92
- // Core Unreal providers (adds feature state + effects internally).
93
- // playwright/testing mode is set via UNREAL_CONFIG above.
94
- // Opt into extra features as needed — see "Bundle size & features" below.
95
- provideUnrealModule(),
96
96
  ],
97
97
  };
98
98
  ```
@@ -103,10 +103,38 @@ If you don't need custom endpoints, provide `UNREAL_CONFIG` with an empty object
103
103
 
104
104
  ```ts
105
105
  { provide: UNREAL_CONFIG, useValue: {} },
106
- provideUnrealModule(),
106
+ provideUnrealState(),
107
107
  ```
108
108
 
109
- ### 2) Use the Unreal scene component
109
+ Every `UnrealInitialConfig` field is optional, but the `UNREAL_CONFIG` provider
110
+ itself is required. Available fields are `playwright`,
111
+ `customErrorsEndpoint`, `commandTelemetryReceiver`, `regionsPingUrl`,
112
+ `screenLockerContainerId`, `dataChannelConnectionTimeout`,
113
+ `streamTelemetryV2Url`, `fpsMonitor` and `autoHighResolution`.
114
+
115
+ ### 2) Boot the engine in each lazy 3D route
116
+
117
+ `provideUnrealModule()` registers the effects and initializes the streaming
118
+ services. Put it on every lazy route that renders a 3D scene:
119
+
120
+ ```ts
121
+ import { Routes } from '@angular/router';
122
+ import { provideUnrealModule } from '@3dsource/angular-unreal-module';
123
+
124
+ export const STREAM_ROUTES: Routes = [
125
+ {
126
+ path: ':id',
127
+ loadComponent: () => import('./stream.component').then((module) => module.StreamComponent),
128
+ providers: [provideUnrealModule()],
129
+ },
130
+ ];
131
+ ```
132
+
133
+ Keep `provideUnrealState()` and `UNREAL_CONFIG` at the root. Keeping
134
+ `provideUnrealModule()` in lazy routes also keeps the streaming engine out of
135
+ the initial application bundle.
136
+
137
+ ### 3) Use the Unreal scene component
110
138
 
111
139
  Import the component into a standalone component and use it in the template.
112
140
 
@@ -118,7 +146,7 @@ import { UnrealSceneComponent } from '@3dsource/angular-unreal-module';
118
146
  selector: 'app-root',
119
147
  standalone: true,
120
148
  imports: [UnrealSceneComponent],
121
- template: ` <app-unreal-scene [isStudio]="false" [useContainerAsSizeProvider]="true" [studioResolutionSize]="{ width: 1920, height: 1080 }" (changeMouseOverScene)="onHover($event)"> </app-unreal-scene> `,
149
+ template: ` <app-unreal-scene [isStudio]="false" [useContainerAsSizeProvider]="true" [resolutionSize]="{ width: 1920, height: 1080 }" (changeMouseOverScene)="onHover($event)" /> `,
122
150
  })
123
151
  export class AppComponent {
124
152
  onHover(isOver: boolean) {
@@ -135,7 +163,7 @@ Inputs:
135
163
  | ---------------------------- | ----------------------------------- | ------------------------------- |
136
164
  | `isStudio` | `boolean` | `false` |
137
165
  | `useContainerAsSizeProvider` | `boolean` | `true` |
138
- | `studioResolutionSize` | `{ width: number; height: number }` | `{ width: 1920, height: 1080 }` |
166
+ | `resolutionSize` | `{ width: number; height: number }` | `{ width: 1920, height: 1080 }` |
139
167
 
140
168
  Outputs:
141
169
 
@@ -143,7 +171,7 @@ Outputs:
143
171
  | ---------------------- | --------------------------- |
144
172
  | `changeMouseOverScene` | `OutputEmitterRef<boolean>` |
145
173
 
146
- ### 3) Send commands / interactions to Unreal
174
+ ### 4) Send commands / interactions to Unreal
147
175
 
148
176
  Inject `UnrealCommunicatorService` to send commands or UI interactions. Types for command packets are provided by `@3dsource/types-unreal`.
149
177
 
@@ -184,14 +212,15 @@ export class MyComponent {
184
212
  }
185
213
  ```
186
214
 
187
- ### 4) Store integration (actions & selectors)
215
+ ### 5) Store integration (actions & selectors)
188
216
 
189
- The module registers an NgRx feature state `commandsFeature`. You can dispatch actions and select state in your components:
217
+ `provideUnrealState()` registers the NgRx feature state `unrealFeature`. You
218
+ can dispatch actions and select state in your components:
190
219
 
191
220
  ```ts
192
221
  import { inject } from '@angular/core';
193
222
  import { Store } from '@ngrx/store';
194
- import { startStream, setConfig, setOrchestrationContext, disconnectStream, selectTotalProgress, selectShowLoader, commandsFeature } from '@3dsource/angular-unreal-module';
223
+ import { disconnectStream, selectShowLoader, selectTotalProgress, setConfig, setOrchestrationContext, startStream, unrealFeature } from '@3dsource/angular-unreal-module';
195
224
 
196
225
  // Dispatch actions
197
226
  const store = inject(Store);
@@ -199,8 +228,8 @@ store.dispatch(startStream({ config: { autoStart: true, warnTimeout: 120 } }));
199
228
 
200
229
  // Select state
201
230
  const progress = store.selectSignal(selectTotalProgress);
202
- const isVideoPlaying = store.selectSignal(commandsFeature.selectIsVideoPlaying);
203
- const dataChannelConnected = store.selectSignal(commandsFeature.selectDataChannelConnected);
231
+ const isVideoPlaying = store.selectSignal(unrealFeature.selectIsVideoPlaying);
232
+ const dataChannelConnected = store.selectSignal(unrealFeature.selectDataChannelConnected);
204
233
  ```
205
234
 
206
235
  **Key selectors:**
@@ -210,9 +239,9 @@ const dataChannelConnected = store.selectSignal(commandsFeature.selectDataChanne
210
239
  - `selectShowReconnectPopup` — Whether reconnect popup should be shown
211
240
  - `selectIsVideoPlayingAndDataChannelConnected` — Combined readiness check
212
241
  - `selectStreamConfig` — Current stream configuration
213
- - `commandsFeature.selectCirrusConnected` — Signalling server connection status
214
- - `commandsFeature.selectDataChannelConnected` — Data channel status
215
- - `commandsFeature.selectViewportReady` — Viewport readiness
242
+ - `unrealFeature.selectCirrusConnected` — Signalling server connection status
243
+ - `unrealFeature.selectDataChannelConnected` — Data channel status
244
+ - `unrealFeature.selectViewportReady` — Viewport readiness
216
245
 
217
246
  **Key actions:**
218
247
 
@@ -223,7 +252,7 @@ const dataChannelConnected = store.selectSignal(commandsFeature.selectDataChanne
223
252
  - `destroyUnrealScene` — Full teardown
224
253
  - `reconnectPeer` — Trigger peer reconnection
225
254
 
226
- ### 5) Listen for Unreal callbacks
255
+ ### 6) Listen for Unreal callbacks
227
256
 
228
257
  Inject `UnrealCallbackService` to listen for callback events from Unreal Engine and to observe command responses.
229
258
 
@@ -282,18 +311,18 @@ export class MyComponent {
282
311
 
283
312
  ### Components
284
313
 
285
- | Component | Selector | Description |
286
- | -------------------------------- | ------------------ | -------------------------------------- |
287
- | `UnrealSceneComponent` | `app-unreal-scene` | Main scene container with video stream |
288
- | `AfkTimeoutModalComponent` | — | AFK timeout warning modal |
289
- | `FreezeFrameComponent` | — | Freeze frame overlay |
290
- | `LowBandwidthModalComponent` | — | Low bandwidth warning modal |
291
- | `LowBandwidthIndicatorComponent` | — | Low bandwidth indicator |
292
- | `ImageLoadingSrcComponent` | — | Loading image overlay |
293
- | `IntroSrcComponent` | — | Intro image/video overlay |
294
- | `VideoStatsComponent` | — | Video statistics display |
295
- | `StatGraphComponent` | — | Statistics graph |
296
- | `WebrtcErrorModalComponent` | — | WebRTC error modal |
314
+ | Component | Selector | Description |
315
+ | ------------------------------- | ------------------ | -------------------------------------- |
316
+ | `UnrealSceneComponent` | `app-unreal-scene` | Main scene container with video stream |
317
+ | `AfkTimeoutModalComponent` | — | AFK timeout warning modal |
318
+ | `FreezeFrameComponent` | — | Freeze frame overlay |
319
+ | `LowBandwidthModalComponent` | — | Low bandwidth warning modal |
320
+ | `LowBandwidthDetectorComponent` | — | Low bandwidth detector |
321
+ | `ImageLoadingSrcComponent` | — | Loading image overlay |
322
+ | `IntroSrcComponent` | — | Intro image/video overlay |
323
+ | `VideoStatsComponent` | — | Video statistics display |
324
+ | `StatGraphComponent` | — | Statistics graph |
325
+ | `WebrtcErrorModalComponent` | — | WebRTC error modal |
297
326
 
298
327
  ### Services
299
328
 
@@ -318,9 +347,9 @@ export class MyComponent {
318
347
 
319
348
  ### Pipes
320
349
 
321
- | Pipe | Description |
322
- | -------------- | ------------------------------- |
323
- | `SafeHtmlPipe` | Bypasses Angular HTML sanitizer |
350
+ | Pipe | Description |
351
+ | ---------- | ------------------------------------------------------------ |
352
+ | `SafePipe` | Sanitizes a value for the requested Angular security context |
324
353
 
325
354
  ### Interfaces
326
355
 
@@ -350,12 +379,34 @@ export class MyComponent {
350
379
  Check the demo application for complete usage examples:
351
380
 
352
381
  ```shell
353
- npm run demo:start
382
+ pnpm demo:start
354
383
  ```
355
384
 
356
385
  See also: `projects/demo/src/app/demo-layout/info-pages/unreal-scene-demo/constants/unreal.routes.ts` for a real-world provider configuration example.
357
386
 
387
+ ## Repository development
388
+
389
+ Run package commands from the repository root:
390
+
391
+ ```shell
392
+ pnpm --filter @3dsource/angular-unreal-module build
393
+ pnpm --filter @3dsource/angular-unreal-module build:watch
394
+ pnpm --filter @3dsource/angular-unreal-module test:signalling
395
+ ```
396
+
397
+ To version and publish:
398
+
399
+ ```shell
400
+ pnpm release:package -- angular-unreal-module patch latest
401
+ ```
402
+
403
+ The release command publishes to npm. After verifying the npm release, purge
404
+ the matching jsDelivr tag explicitly:
405
+
406
+ ```shell
407
+ pnpm --filter @3dsource/angular-unreal-module purge-cdn -- latest
408
+ ```
409
+
358
410
  ## Engine requirements
359
411
 
360
412
  - Node.js: >=20
361
- - npm: >9
@@ -6,7 +6,6 @@ import { createAction, props, createReducer, on, createFeature, Store, createSel
6
6
  import { whereNot, Falsy, Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, generateUuid, Truthy, tapLog, Semaphore, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
7
7
  import { filter, map as map$1, distinctUntilChanged, takeWhile, takeUntil, catchError, tap, timeout, finalize, first, switchMap as switchMap$1, withLatestFrom, exhaustMap, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
8
8
  import { Actions, ofType, createEffect, provideEffects } from '@ngrx/effects';
9
- import { concatLatestFrom } from '@ngrx/operators';
10
9
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
11
10
  import { HttpClient } from '@angular/common/http';
12
11
  import { ScrollStrategyOptions } from '@angular/cdk/overlay';
@@ -3329,7 +3328,8 @@ class WebRtcPlayerService extends SubService {
3329
3328
  * (`pc.onicecandidate`, `dc.onmessage`, …) and buffered inbound DC messages;
3330
3329
  * here we detach those, wire our own (addEventListener-based) handlers, replay
3331
3330
  * the buffer in order, then drive `onDataChannelConnected` manually because the
3332
- * DataChannel is already open (its `open` event won't fire again).
3331
+ * DataChannel is already open. A native `open` event may still be queued, so the
3332
+ * adopted channel deliberately does not subscribe to it.
3333
3333
  */
3334
3334
  adopt(pc, dc, stream, config, dcBuffer) {
3335
3335
  // No prior pc/dc should exist on a fresh boot; closePC is idempotent.
@@ -3354,7 +3354,9 @@ class WebRtcPlayerService extends SubService {
3354
3354
  dc.onmessage = null;
3355
3355
  dc.onopen = null;
3356
3356
  dc.onclose = null;
3357
- this.dcClient = this.setupDataChannelCallbacks(dc);
3357
+ // Adoption announces the already-open channel manually below. Do not
3358
+ // subscribe to a native `open` event that may still be queued.
3359
+ this.dcClient = this.setupDataChannelCallbacks(dc, false);
3358
3360
  for (const buffered of dcBuffer) {
3359
3361
  this.onDataChannelMessage$.next(buffered);
3360
3362
  }
@@ -3498,7 +3500,7 @@ class WebRtcPlayerService extends SubService {
3498
3500
  }
3499
3501
  }
3500
3502
  }
3501
- setupDataChannelCallbacks(datachannel) {
3503
+ setupDataChannelCallbacks(datachannel, subscribeToOpen = true) {
3502
3504
  try {
3503
3505
  Logger.log(`[DATACHANNEL] Setting up datachannel "${datachannel.label}" callbacks...`);
3504
3506
  this.commandTelemetryService.trackStartCommand('EXT-DataChannel Connection');
@@ -3521,14 +3523,16 @@ class WebRtcPlayerService extends SubService {
3521
3523
  const message = `[DATACHANNEL] Data channel "${datachannel.label}" error. id:${datachannel.id}`;
3522
3524
  Logger.error(message, e);
3523
3525
  });
3524
- fromEvent(datachannel, 'open')
3525
- .pipe(tap$1(() => {
3526
- Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
3527
- }), takeUntil(merge(this.disconnect$, this.destroy$)))
3528
- .subscribe(() => {
3529
- this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
3530
- this.onDataChannelConnected();
3531
- });
3526
+ if (subscribeToOpen) {
3527
+ fromEvent(datachannel, 'open')
3528
+ .pipe(tap$1(() => {
3529
+ Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
3530
+ }), takeUntil(merge(this.disconnect$, this.destroy$)))
3531
+ .subscribe(() => {
3532
+ this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
3533
+ this.onDataChannelConnected();
3534
+ });
3535
+ }
3532
3536
  fromEvent(datachannel, 'message')
3533
3537
  .pipe(
3534
3538
  /* tap((e: MessageEvent) => {
@@ -4058,10 +4062,10 @@ class UnrealEffects {
4058
4062
  * a visible flicker (video flashing to black).
4059
4063
  */
4060
4064
  this.forceViewportNotReady$ = createEffect(() => {
4061
- return this.actions$.pipe(ofType(destroyRemoteConnections), concatLatestFrom(() => this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), map(() => setViewportNotReady()));
4065
+ return this.actions$.pipe(ofType(destroyRemoteConnections), withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady), map(() => setViewportNotReady()));
4062
4066
  });
4063
4067
  this.resetCirrusOnDestroy$ = createEffect(() => {
4064
- return this.actions$.pipe(ofType(destroyRemoteConnections), concatLatestFrom(() => this.store.select(unrealFeature.selectCirrusConnected)), filter(([, cirrusConnected]) => cirrusConnected), map(() => setCirrusDisconnected()));
4068
+ return this.actions$.pipe(ofType(destroyRemoteConnections), withLatestFrom(this.store.select(unrealFeature.selectCirrusConnected)), filter(([, cirrusConnected]) => cirrusConnected), map(() => setCirrusDisconnected()));
4065
4069
  });
4066
4070
  this.destroyRemoteConnections$ = createEffect(() => {
4067
4071
  return this.actions$.pipe(ofType(destroyRemoteConnections), tap(({ reason }) => {
@@ -4069,7 +4073,7 @@ class UnrealEffects {
4069
4073
  }), filter(({ reason }) => reason === DisconnectReason.DropConnection), map(() => dropConnection()));
4070
4074
  });
4071
4075
  this.destroyConnectionAndRestart$ = createEffect(() => {
4072
- return this.actions$.pipe(ofType(setCirrusDisconnected), concatLatestFrom(() => this.store.select(unrealFeature.selectDisconnectReason)), filter(([, reason]) => reason === DisconnectReason.DataChannelTimeout), map(() => initSignalling()));
4076
+ return this.actions$.pipe(ofType(setCirrusDisconnected), withLatestFrom(this.store.select(unrealFeature.selectDisconnectReason)), filter(([, reason]) => reason === DisconnectReason.DataChannelTimeout), map(() => initSignalling()));
4073
4077
  });
4074
4078
  this.showUnrealError$ = createEffect(() => {
4075
4079
  return this.actions$.pipe(ofType(showUnrealErrorMessage), map(({ code }) => getRtcErrorMessage(code)), distinctUntilChanged(), filter(Truthy), switchMap((content) => this.dialog.open(UnrealErrorModalComponent, {
@@ -4078,7 +4082,7 @@ class UnrealEffects {
4078
4082
  }).closed), map(() => showUnrealErrorMessage({ code: null })));
4079
4083
  });
4080
4084
  this.unrealReceiverReady$ = createEffect(() => {
4081
- return this.actions$.pipe(ofType(dataChannelConnected), concatLatestFrom(() => this.store.select(unrealFeature.selectIsReconnecting)), filter(([, isReconnecting]) => !isReconnecting), map(() => dataChannelReady()));
4085
+ return this.actions$.pipe(ofType(dataChannelConnected), withLatestFrom(this.store.select(unrealFeature.selectIsReconnecting)), filter(([, isReconnecting]) => !isReconnecting), map(() => dataChannelReady()));
4082
4086
  });
4083
4087
  this.initAppByDataChannelReady$ = createEffect(() => {
4084
4088
  return this.actions$.pipe(ofType(dataChannelReady), tap(() => {