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

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.
@@ -4057,2091 +4057,2102 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
4057
4057
  type: Injectable
4058
4058
  }], ctorParameters: () => [] });
4059
4059
 
4060
- class AfkPlaywrightService extends AFKService {
4061
- init() {
4062
- return;
4063
- }
4064
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4065
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService }); }
4066
- }
4067
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService, decorators: [{
4068
- type: Injectable
4069
- }] });
4070
-
4071
- class SignallingPlaywrightService extends SignallingService {
4060
+ /*
4061
+ * Copyright (c) 2026.
4062
+ * 3DSource.com. Sergii Karanda steve@3dsource.com. All Rights Reserved.
4063
+ */
4064
+ class WebRtcPlayerService2 extends SubService {
4072
4065
  constructor() {
4073
4066
  super();
4067
+ this.onDataChannelMessage$ = new Subject();
4068
+ this.signallingSrv = inject(SignallingService);
4069
+ this.videoService = inject(VideoService);
4070
+ this.commandTelemetryService = inject(CommandTelemetryService);
4071
+ this.cfg = {};
4072
+ this.useMic = false;
4073
+ this.forceTURN = false;
4074
+ this.forceMonoAudio = false;
4075
+ /** Whether the remote SDP has been applied to the current peer connection */
4076
+ this.remoteDescriptionSet = false;
4077
+ /** ICE candidates received before the remote description was set */
4078
+ this.iceCandidateBuffer = [];
4079
+ this.isReconnecting = this.store.selectSignal(unrealFeature.selectIsReconnecting);
4080
+ this.init();
4081
+ this.actions$.pipe(ofType(dropDC)).subscribe(() => this.dcClient?.close());
4082
+ this.actions$.pipe(ofType(dropPC)).subscribe(() => this.pcClient?.close());
4074
4083
  }
4075
4084
  init() {
4076
- return;
4077
- }
4078
- connectToSignaling() {
4079
- this.store.dispatch(setUnrealPlaywrightConfig());
4080
- this.store.dispatch(dataChannelConnected({
4081
- statusMessage: 'Playwright set data channel connected',
4082
- }));
4083
- return EMPTY;
4084
- }
4085
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4086
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService }); }
4087
- }
4088
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService, decorators: [{
4089
- type: Injectable
4090
- }], ctorParameters: () => [] });
4091
-
4092
- class UnrealCommunicatorPlaywrightService extends UnrealCommunicatorService {
4093
- init() {
4094
- return;
4095
- }
4096
- sendCommandToUnreal() {
4097
- return;
4085
+ this.signallingSrv.onConfig$.subscribe((config) => {
4086
+ this.videoService.create();
4087
+ this.setConfig(config);
4088
+ });
4089
+ // Server-initiated offer: set remote description and respond with answer
4090
+ this.signallingSrv.onOffer$.pipe(filter(Truthy)).subscribe((offer) => {
4091
+ void this.handleServerOffer(offer);
4092
+ });
4093
+ // Answer to a client-initiated offer
4094
+ this.signallingSrv.onWebRtcAnswer$
4095
+ .pipe(filter(Truthy))
4096
+ .subscribe((answer) => {
4097
+ void this.handleServerAnswer(answer);
4098
+ });
4099
+ // ICE candidates — buffered until remote description is set
4100
+ this.signallingSrv.onWebRtcIce$.subscribe((candidate) => {
4101
+ this.commandTelemetryService.trackStopCommand('EXT-iceCandidate', true, {
4102
+ multi: true,
4103
+ });
4104
+ if (this.remoteDescriptionSet) {
4105
+ this.handleCandidateFromServer(candidate);
4106
+ }
4107
+ else {
4108
+ this.iceCandidateBuffer.push(candidate);
4109
+ }
4110
+ });
4098
4111
  }
4099
- sendInputData() {
4100
- return;
4112
+ /**
4113
+ * Handles an SDP offer received from the server.
4114
+ * Creates a new peer connection, applies the remote offer,
4115
+ * then responds with an SDP answer.
4116
+ *
4117
+ * The DataChannel is NOT created here — the server creates it
4118
+ * and we receive it via the `datachannel` event on the peer connection.
4119
+ */
4120
+ async handleServerOffer(offer) {
4121
+ Logger.info('Received offer from server, creating answer');
4122
+ try {
4123
+ this.remoteDescriptionSet = false;
4124
+ this.iceCandidateBuffer.length = 0;
4125
+ if (!this.pcClient) {
4126
+ this.setupPeerConnection(this.cfg);
4127
+ }
4128
+ // Offer already defines media transceivers — don't add extras
4129
+ await this.pcClient.setRemoteDescription(offer);
4130
+ this.remoteDescriptionSet = true;
4131
+ this.flushIceCandidateBuffer();
4132
+ await this.createAnswer(this.pcClient);
4133
+ }
4134
+ catch (e) {
4135
+ Logger.error('Failed to handle server offer', e);
4136
+ this.store.dispatch(disconnectStream({
4137
+ reason: DisconnectReason.WebRTCError,
4138
+ message: 'WebRTCError: Failed to handle server offer',
4139
+ }));
4140
+ }
4101
4141
  }
4102
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4103
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService }); }
4104
- }
4105
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService, decorators: [{
4106
- type: Injectable
4107
- }] });
4108
-
4109
- class AggregatorPlaywrightService extends AggregatorService {
4110
- init() {
4111
- return;
4142
+ /**
4143
+ * Handles an SDP answer received from the server in response to our offer.
4144
+ * Applies the remote description and flushes any buffered ICE candidates.
4145
+ */
4146
+ async handleServerAnswer(answer) {
4147
+ Logger.info('Received answer from server');
4148
+ try {
4149
+ await this.pcClient?.setRemoteDescription(answer);
4150
+ this.remoteDescriptionSet = true;
4151
+ this.flushIceCandidateBuffer();
4152
+ }
4153
+ catch (e) {
4154
+ Logger.error('Failed to set remote description from answer', e);
4155
+ }
4112
4156
  }
4113
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4114
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService }); }
4115
- }
4116
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService, decorators: [{
4117
- type: Injectable
4118
- }] });
4119
-
4120
- class FreezeFramePlaywrightService extends FreezeFrameService {
4121
- init() {
4122
- return;
4157
+ /** Drains buffered ICE candidates after the remote description has been set */
4158
+ flushIceCandidateBuffer() {
4159
+ for (const candidate of this.iceCandidateBuffer) {
4160
+ this.handleCandidateFromServer(candidate);
4161
+ }
4162
+ this.iceCandidateBuffer.length = 0;
4123
4163
  }
4124
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4125
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService }); }
4126
- }
4127
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService, decorators: [{
4128
- type: Injectable
4129
- }] });
4130
-
4131
- class CommandTelemetryPlaywrightService extends CommandTelemetryService {
4132
- init() {
4133
- return;
4164
+ /**
4165
+ * Creates a client-initiated WebRTC offer and sends it to the server.
4166
+ * When the client offers, it also creates the DataChannel.
4167
+ * Used for peer reconnection through the existing signaling connection.
4168
+ */
4169
+ async createWebRtcOffer() {
4170
+ Logger.info('Creating client-initiated offer');
4171
+ try {
4172
+ this.remoteDescriptionSet = false;
4173
+ this.iceCandidateBuffer.length = 0;
4174
+ if (!this.pcClient) {
4175
+ this.setupPeerConnection(this.cfg);
4176
+ }
4177
+ // Client offers: transceivers first, then DC, then offer
4178
+ await this.setupTransceiversAsync(this.pcClient);
4179
+ this.dcClient = this.createDataChannel(this.pcClient, 'UnrealDcConnection', { ordered: true });
4180
+ await this.createOffer(this.pcClient);
4181
+ }
4182
+ catch (e) {
4183
+ Logger.error('Failed to create WebRTC offer', e);
4184
+ this.store.dispatch(disconnectStream({
4185
+ reason: DisconnectReason.WebRTCError,
4186
+ message: 'WebRTCError: Offer Error',
4187
+ }));
4188
+ }
4134
4189
  }
4135
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4136
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService }); }
4137
- }
4138
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService, decorators: [{
4139
- type: Injectable
4140
- }] });
4141
-
4142
- class StreamStatusTelemetryPlaywrightService extends StreamStatusTelemetryService {
4143
- init() {
4144
- return;
4190
+ /**
4191
+ * Initializes a new RTCPeerConnection with event handlers.
4192
+ * Transceivers are NOT added here — the caller is responsible for adding
4193
+ * them at the correct point depending on the negotiation flow.
4194
+ */
4195
+ setupPeerConnection(config) {
4196
+ this.closePC();
4197
+ Logger.log('Creating new RTCPeerConnection with config');
4198
+ this.pcClient = new RTCPeerConnection(config);
4199
+ this.commandTelemetryService.trackStartCommand('EXT-Browser ICE gathering');
4200
+ this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
4201
+ this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
4202
+ this.handlePeerConnectionEvents(this.pcClient);
4203
+ this.disconnect$.pipe(take(1)).subscribe(() => {
4204
+ this.closePC();
4205
+ });
4145
4206
  }
4146
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4147
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService }); }
4148
- }
4149
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService, decorators: [{
4150
- type: Injectable
4151
- }] });
4152
-
4153
- class ConsoleExtensionsPlaywrightService extends ConsoleExtensionsService {
4154
- init() {
4155
- return;
4207
+ createDataChannel(pc, label, options) {
4208
+ const datachannel = pc.createDataChannel(label, options);
4209
+ return this.setupDataChannelCallbacks(datachannel);
4156
4210
  }
4157
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4158
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService }); }
4159
- }
4160
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService, decorators: [{
4161
- type: Injectable
4162
- }] });
4163
-
4164
- class FileReceiverPlaywrightService extends FileReceiverService {
4165
- init() {
4166
- return;
4167
- }
4168
- }
4169
-
4170
- class InputPlaywrightService extends InputService {
4171
- init() {
4172
- return;
4173
- }
4174
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4175
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService }); }
4176
- }
4177
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService, decorators: [{
4178
- type: Injectable
4179
- }] });
4180
-
4181
- const ReceivedMimeTypes = {
4182
- ApplicationJson: 'application/json',
4183
- };
4184
-
4185
- class FileHandlerService {
4186
- constructor() {
4187
- this.fileService = inject(FileReceiverService);
4188
- this.fileHandlers = {
4189
- [ReceivedMimeTypes.ApplicationJson]: this.handleJsonFile.bind(this),
4190
- };
4191
- }
4192
- handleJsonFile(data, correlationId) {
4193
- const { blob } = data;
4194
- return from(blob.text()).pipe(map$1((text) => JSON.parse(text)), filter(({ commandCallback }) => {
4195
- return commandCallback.correlationId === correlationId;
4196
- }));
4197
- }
4198
- observeFileResponse(mimetype, data, sender, timeOut = 60000) {
4199
- const correlationId = generateUuid();
4200
- const out = { ...data, correlationId };
4201
- const observable = this.fileService.fileComplete$.pipe(switchMap((data) => {
4202
- if (data.valid && this.fileHandlers[mimetype]) {
4203
- return this.fileHandlers[mimetype](data, correlationId).pipe(filter(Truthy));
4204
- }
4205
- return of(null);
4206
- }), timeout(timeOut), catchError((e) => {
4207
- Logger.error(e);
4208
- return of(null);
4209
- }), take(1));
4210
- setTimeout(() => sender(out), 0);
4211
- return observable;
4212
- }
4213
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4214
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService }); }
4215
- }
4216
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService, decorators: [{
4217
- type: Injectable
4218
- }] });
4219
-
4220
- class AnalyticsService {
4221
- #store = inject(Store);
4222
- #destroy$ = new Subject();
4223
- sendEventToMixPanel(name, params = {}) {
4224
- Logger.log('Analytics Event:', name, params);
4225
- this.#store.dispatch(saveAnalyticsEvent({ event: { name, params } }));
4226
- }
4227
- constructor() {
4228
- const abortSignal$ = this.#store
4229
- .select(unrealFeature.selectViewportReady)
4230
- .pipe(filter$1(Falsy), map(() => void 0));
4231
- this.#store
4232
- .select(unrealFeature.selectViewportReady)
4233
- .pipe(distinctUntilChanged(), filter$1(Truthy), tap(() => this.monitorMouse(abortSignal$)))
4234
- .subscribe();
4235
- }
4236
- monitorMouse(abortSignal$) {
4237
- this.#destroy$.next();
4238
- const stop$ = merge(this.#destroy$.asObservable(), abortSignal$);
4239
- const element = document.getElementById('streamingVideo');
4240
- if (!element) {
4241
- return;
4242
- }
4243
- // Monitor mousewheel events
4244
- const mousewheel$ = fromEvent(element, 'wheel').pipe(takeUntil$1(stop$), throttleTime(1000));
4245
- // Monitor mouse button states
4246
- const mousedown$ = fromEvent(element, 'mousedown').pipe(takeUntil$1(stop$));
4247
- const touchStart$ = fromEvent(element, 'touchstart').pipe(takeUntil$1(stop$));
4248
- const touchMove$ = fromEvent(element, 'touchmove').pipe(takeUntil$1(stop$), throttleTime(200));
4249
- // Track mouse movement while the left or right button is pressed
4250
- mousedown$
4251
- .pipe(filter$1((event) => event.button === 0 || event.button === 2), tap((downEvent) => {
4252
- this.sendEventToMixPanel('mouse_interaction', {
4253
- viewX: downEvent.clientX / element.clientWidth,
4254
- viewY: downEvent.clientY / element.clientHeight,
4255
- action: downEvent.button === 0 ? 'spinning' : 'panning',
4256
- });
4257
- }))
4258
- .subscribe();
4259
- // Track mouse movement while the left or right button is pressed
4260
- touchStart$
4261
- .pipe(tap((downEvent) => {
4262
- const totalTouches = downEvent.touches.length;
4263
- const touch = downEvent.touches[0];
4264
- this.sendEventToMixPanel('touch_interaction', {
4265
- viewX: touch.clientX / element.clientWidth,
4266
- viewY: touch.clientY / element.clientHeight,
4267
- action: totalTouches === 1 ? 'spinning' : 'panning',
4268
- });
4269
- }))
4270
- .subscribe();
4271
- let initialTouchDistance = 0;
4272
- // Subscribe to mousewheel events
4273
- touchMove$.subscribe((event) => {
4274
- const totalTouches = event.touches.length;
4275
- if (totalTouches < 2) {
4276
- return;
4277
- }
4278
- // Calculate the current distance between two touches
4279
- const touch1 = event.touches[0];
4280
- const touch2 = event.touches[1];
4281
- const currentDistance = Math.sqrt(Math.pow(touch2.clientX - touch1.clientX, 2) +
4282
- Math.pow(touch2.clientY - touch1.clientY, 2));
4283
- // Determine if zooming in or out based on distance change
4284
- const deltaDistance = currentDistance - initialTouchDistance;
4285
- const isZoomingIn = deltaDistance > 0;
4286
- // Only send event if there's a significant change (avoid noise)
4287
- if (Math.abs(deltaDistance) > 10) {
4288
- this.sendEventToMixPanel('touch_zoom_interaction', {
4289
- delta: isZoomingIn ? 1 : -1,
4290
- action: isZoomingIn ? 'zoomin' : 'zoomout',
4291
- });
4292
- // Update initial distance for next comparison
4293
- initialTouchDistance = currentDistance;
4294
- }
4295
- });
4296
- mousewheel$.subscribe((event) => {
4297
- this.sendEventToMixPanel('mouse_zoom_interaction', {
4298
- delta: -(event.deltaY / Math.abs(event.deltaY)),
4299
- action: event.deltaY < 0 ? 'zoomin' : 'zoomout',
4300
- });
4301
- });
4302
- }
4303
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4304
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService }); }
4305
- }
4306
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService, decorators: [{
4307
- type: Injectable
4308
- }], ctorParameters: () => [] });
4309
-
4310
- /**
4311
- * Adaptive FPS controller that monitors the incoming WebRTC video stream
4312
- * quality and dynamically adjusts Unreal Engine's `t.MaxFPS` console variable.
4313
- *
4314
- * ## How it works
4315
- *
4316
- * The service subscribes to `VideoService.videoStats$` (emitted every 250 ms)
4317
- * and collects samples of FPS, bitrate, and QP (Quantisation Parameter).
4318
- * Every {@link SAMPLE_WINDOW} ticks (~2 s) it averages the collected samples
4319
- * and feeds them into a set of **fuzzy-logic membership functions** that
4320
- * classify each metric into overlapping quality bands.
4321
- *
4322
- * ### Input signals
4323
- *
4324
- * **1. FPS ratio** = `actualFPS / currentTargetFPS` (0..1+)
4325
- *
4326
- * | Band | Range | Meaning |
4327
- * |--------|---------------------|-----------------------------------------------|
4328
- * | low | ratio < 0.7 → 1.0, linear ramp 0.7..0.9 → 0 | Stream cannot keep up with the target |
4329
- * | ok | triangle 0.8..1.0, peak at 0.95 | Stream roughly matches the target |
4330
- * | high | ratio 0.9..1.0 → ramp, >1.0 → 1.0 | Stream meets or exceeds the target |
4331
- *
4332
- * **2. Bitrate load** = `currentBitrate / MAX_BITRATE` (0..1+)
4333
- *
4334
- * `MAX_BITRATE` = 20 Mbit/s — the reference ceiling for the encoder.
4335
- *
4336
- * | Band | Range | Meaning |
4337
- * |--------|-------------------------------------------------|-----------------------------------------------|
4338
- * | high | load 0.5..0.8 → ramp, >0.8 → 1.0 | Encoder is using most of the available bandwidth |
4339
- * | not high | load < 0.5 → 0.0 | Bandwidth headroom is available |
4340
- *
4341
- * **3. Quality** — mapped from QP via `mapQpToQuality()` (see {@link Quality}):
4342
- *
4343
- * | Quality | QP range | Meaning |
4344
- * |----------|-----------|-------------------------------------------------|
4345
- * | `lime` | QP <= 26 | Good — encoder has enough headroom |
4346
- * | `orange` | QP 27..35 | Fair — encoder is under moderate pressure |
4347
- * | `red` | QP > 35 | Poor — heavy compression, visible artefacts |
4348
- *
4349
- * ### Decision rules (evaluated in priority order, first match wins)
4350
- *
4351
- * | # | Condition | Decision |
4352
- * |---|------------------------------------------------------------|--------------|
4353
- * | 1 | FPS ratio is **low** (membership > 0.5) | **decrease** |
4354
- * | 2 | FPS ratio is **ok** AND quality is `red` | **decrease** |
4355
- * | 3 | FPS ratio is **ok** AND bitrate **high** AND quality is `orange` | **hold** |
4356
- * | 4 | FPS ratio is **high** AND quality is `lime` AND bitrate **not high** | **increase** |
4357
- * | — | Everything else | **hold** |
4358
- *
4359
- * ## Cooldown-based throttling
4360
- *
4361
- * The service continuously evaluates fuzzy rules every sample window.
4362
- * After each FPS step change it enforces a cooldown before the next
4363
- * change can be applied. The cooldown duration depends on the
4364
- * (previous decision → next decision) pair:
4365
- *
4366
- * | Previous | Next | Cooldown |
4367
- * |-----------|-----------|-----------------------------------------------------|
4368
- * | decrease | decrease | {@link COOLDOWN_DECREASE_AFTER_DECREASE_MS} (2 s) |
4369
- * | decrease | increase | {@link COOLDOWN_INCREASE_AFTER_DECREASE_MS} (10 s) |
4370
- * | increase | decrease | {@link COOLDOWN_AFTER_INCREASE_MS} (2 s) |
4371
- * | increase | increase | {@link COOLDOWN_AFTER_INCREASE_MS} (2 s) |
4372
- *
4373
- * This means the system reacts quickly after an increase (2 s in any
4374
- * direction) but is cautious about stepping back up after a decrease
4375
- * (10 s), while still allowing consecutive decreases at 2 s intervals.
4376
- *
4377
- * ## FPS dispatch
4378
- *
4379
- * FPS changes are dispatched via the NgRx `setMaxFps` action, which triggers
4380
- * the existing `CommandsEffects.setMaxFps$` effect that sends the
4381
- * `t.MaxFPS <value>` console command to Unreal Engine.
4382
- */
4383
- class FpsMonitorService {
4384
- constructor() {
4385
- this.videoService = inject(VideoService);
4386
- this.store = inject(Store);
4387
- /** Discrete FPS targets the controller can switch between (ascending order). */
4388
- this.FPS_STEPS = [30, 40, 50, 60];
4389
- /** Reference ceiling for bitrate (bits/sec) used to normalise bitrate load to 0..1. */
4390
- this.MAX_BITRATE = 20_000_000;
4391
- /**
4392
- * Cooldown (ms) for any decision (increase or decrease) following an increase.
4393
- * After an increase the system should react faster.
4394
- */
4395
- this.COOLDOWN_AFTER_INCREASE_MS = 2_000;
4396
- /**
4397
- * Cooldown (ms) for a decrease following a previous decrease.
4398
- */
4399
- this.COOLDOWN_DECREASE_AFTER_DECREASE_MS = 2_000;
4400
- /**
4401
- * Cooldown (ms) for an increase following a previous decrease.
4402
- * The stream needs more time to stabilise before stepping back up.
4403
- */
4404
- this.COOLDOWN_INCREASE_AFTER_DECREASE_MS = 10_000;
4405
- /**
4406
- * Number of videoStats$ ticks to accumulate before evaluating.
4407
- * At 250 ms per tick this gives a ~2 s sliding evaluation window,
4408
- * long enough to smooth out single-frame jitter.
4409
- */
4410
- this.SAMPLE_WINDOW = 8;
4411
- /** Current FPS target; starts at max (60 FPS) and adapts downward/upward. */
4412
- this.currentFpsTarget = this.FPS_STEPS[this.FPS_STEPS.length - 1];
4413
- this.samples = [];
4414
- this.lastDecisionTime = Date.now();
4415
- this.lastUpgrade = 'hold';
4416
- this.init();
4417
- }
4418
- init() {
4419
- this.videoService.videoStats$
4420
- .pipe(withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady))
4421
- .subscribe(([stats]) => this.processTick(stats));
4422
- }
4423
- /**
4424
- * Called on every videoStats$ emission (~250 ms).
4425
- * Accumulates samples and evaluates once per SAMPLE_WINDOW.
4426
- */
4427
- processTick(stats) {
4428
- const { framesPerSecond, bitrate, VideoEncoderQP } = stats.aggregatedStats;
4429
- this.samples.push({
4430
- fps: framesPerSecond ?? 0,
4431
- bitrate: bitrate ?? 0,
4432
- qp: VideoEncoderQP ?? 0,
4433
- });
4434
- // Wait until we have enough samples for a reliable average.
4435
- if (this.samples.length < this.SAMPLE_WINDOW) {
4436
- return;
4437
- }
4438
- const avg = this.averageSamples();
4439
- this.samples = [];
4440
- this.handleMonitoring(avg, this.currentFpsTarget);
4441
- }
4442
- handleMonitoring(avg, currentTarget) {
4443
- const decision = this.evaluateDecision(avg, currentTarget);
4444
- this.upgradeFps(decision, avg.qp);
4445
- }
4446
- /**
4447
- * Combines fuzzy membership values into a single discrete decision.
4448
- * Rules are evaluated in priority order — first match wins.
4449
- *
4450
- * Inputs:
4451
- * - `fpsRatio` = avgFPS / currentTargetFPS (e.g. 45/60 = 0.75)
4452
- * - `bitrateLoad` = avgBitrate / 20 Mbit/s (e.g. 12M/20M = 0.6)
4453
- * - `quality` = mapQpToQuality(avgQP) → 'lime' | 'orange' | 'red'
4454
- */
4455
- evaluateDecision(avg, currentTarget) {
4456
- const fpsRatio = currentTarget > 0 ? avg.fps / currentTarget : 1;
4457
- const bitrateLoad = avg.bitrate / this.MAX_BITRATE;
4458
- const fpsLow = this.membershipFpsLow(fpsRatio);
4459
- const fpsOk = this.membershipFpsOk(fpsRatio);
4460
- const fpsHigh = this.membershipFpsHigh(fpsRatio);
4461
- const bitrateHigh = this.membershipBitrateHigh(bitrateLoad);
4462
- // Quality is derived from QP: lime (QP <= 26), orange (QP 27..35), red (QP > 35).
4463
- const quality = qpToQuality(avg.qp);
4464
- // Rule 1: FPS clearly below target (ratio roughly < 0.8) → step down immediately.
4465
- if (fpsLow > 0.5) {
4466
- return 'decrease';
4467
- }
4468
- // Rule 2: FPS looks acceptable numerically, but encoder quality is 'red'
4469
- // (QP > 35 — heavy compression artefacts). Reducing target FPS frees
4470
- // encoder headroom and improves visual quality.
4471
- if (fpsOk > 0.4 && quality === 'red') {
4472
- return 'decrease';
4473
- }
4474
- // Rule 3: FPS is ok, but bitrate is above ~50-80% of 20 Mbit/s ceiling
4475
- // and quality is 'orange' (QP 27..35). Near capacity — don't push higher.
4476
- if (fpsOk > 0.4 && bitrateHigh > 0.5 && quality === 'orange') {
4477
- return 'hold';
4478
- }
4479
- // Rule 4: FPS meets/exceeds target (ratio >= ~0.95), quality is 'lime'
4480
- // (QP <= 26), and bitrate is below 50% of ceiling — safe to step up.
4481
- if (fpsHigh > 0.5 &&
4482
- quality === 'lime' &&
4483
- bitrateHigh < 0.5 &&
4484
- this.currentFpsTarget < this.FPS_STEPS[this.FPS_STEPS.length - 1]) {
4485
- return 'increase';
4486
- }
4487
- return 'hold';
4488
- }
4489
- // ---------------------------------------------------------------------------
4490
- // Fuzzy membership functions
4491
- //
4492
- // Each function maps a normalised input (0..1+) to a membership degree (0..1).
4493
- // Overlapping trapezoid/triangle shapes let multiple categories be partially
4494
- // active at the same time, which makes the rules less brittle than hard thresholds.
4495
- // ---------------------------------------------------------------------------
4496
- /**
4497
- * FPS ratio membership "low".
4498
- * Input: ratio = actualFPS / targetFPS (e.g. 42/60 = 0.70).
4499
- *
4500
- * ratio < 0.7 → 1.0 (clearly failing, e.g. 40/60)
4501
- * ratio 0.7..0.9 → linear ramp down (e.g. 0.8 → 0.5)
4502
- * ratio > 0.9 → 0.0 (keeping up)
4503
- */
4504
- membershipFpsLow(ratio) {
4505
- if (ratio < 0.7)
4506
- return 1.0;
4507
- if (ratio > 0.9)
4508
- return 0.0;
4509
- return (0.9 - ratio) / 0.2;
4211
+ handleCandidateFromServer(iceCandidate) {
4212
+ try {
4213
+ const candidate = new RTCIceCandidate(iceCandidate);
4214
+ Logger.info('[Unreal ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
4215
+ // If forcing TURN, reject any candidates not relay
4216
+ if (this.forceTURN) {
4217
+ if (candidate.candidate.indexOf('relay') < 0) {
4218
+ Logger.warn('Dropping candidate because it was not TURN relay.', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
4219
+ return;
4220
+ }
4221
+ }
4222
+ this.pcClient?.addIceCandidate(candidate).catch((e) => {
4223
+ Logger.warn('Failed to add ICE candidate', e);
4224
+ });
4225
+ }
4226
+ catch (e) {
4227
+ Logger.error('Error handleCandidateFromServer', e);
4228
+ }
4510
4229
  }
4511
4230
  /**
4512
- * FPS ratio membership "ok" — triangle shape.
4513
- * Input: ratio = actualFPS / targetFPS.
4514
- *
4515
- * ratio < 0.8 → 0.0
4516
- * ratio 0.8..0.95 → ramp up, peak at 0.95 (e.g. 57/60)
4517
- * ratio 0.95..1.0 → ramp down
4518
- * ratio > 1.0 → 0.0
4519
- */
4520
- membershipFpsOk(ratio) {
4521
- if (ratio < 0.8)
4522
- return 0.0;
4523
- if (ratio < 0.95)
4524
- return (ratio - 0.8) / 0.15;
4525
- if (ratio <= 1.0)
4526
- return (1.0 - ratio) / 0.05;
4527
- return 0.0;
4528
- }
4529
- /**
4530
- * FPS ratio membership "high".
4531
- * Input: ratio = actualFPS / targetFPS.
4532
- *
4533
- * ratio < 0.9 → 0.0
4534
- * ratio 0.9..1.0 → linear ramp up (e.g. 0.95 → 0.5)
4535
- * ratio >= 1.0 → 1.0 (meeting or exceeding target, e.g. 60/60)
4536
- */
4537
- membershipFpsHigh(ratio) {
4538
- if (ratio < 0.9)
4539
- return 0.0;
4540
- if (ratio > 1.0)
4541
- return 1.0;
4542
- return (ratio - 0.9) / 0.1;
4543
- }
4544
- /**
4545
- * Bitrate load membership "high".
4546
- * Input: load = currentBitrate / MAX_BITRATE (20 Mbit/s).
4231
+ * Reorders the `m=video` line in the SDP so that payload types matching
4232
+ * the given H.264 `profile-level-id` appear first, making the browser
4233
+ * prefer that profile during codec negotiation.
4547
4234
  *
4548
- * load < 0.5 → 0.0 (below 10 Mbit/s — plenty of headroom)
4549
- * load 0.5..0.8 → linear ramp (e.g. 13M/20M = 0.65 → 0.5)
4550
- * load > 0.8 → 1.0 (above 16 Mbit/s — near capacity)
4551
- */
4552
- membershipBitrateHigh(load) {
4553
- if (load < 0.5)
4554
- return 0.0;
4555
- if (load > 0.8)
4556
- return 1.0;
4557
- return (load - 0.5) / 0.3;
4558
- }
4559
- // ---------------------------------------------------------------------------
4560
- // Step actions
4561
- // ---------------------------------------------------------------------------
4562
- upgradeFps(decision, qp) {
4563
- if (decision === 'hold')
4564
- return;
4565
- const now = Date.now();
4566
- // Throttle: cooldown depends on (previous decision → current decision) pair.
4567
- const isStepUp = decision === 'increase';
4568
- const cooldown = this.lastUpgrade === 'decrease'
4569
- ? isStepUp
4570
- ? this.COOLDOWN_INCREASE_AFTER_DECREASE_MS
4571
- : this.COOLDOWN_DECREASE_AFTER_DECREASE_MS
4572
- : this.COOLDOWN_AFTER_INCREASE_MS;
4573
- if (now - this.lastDecisionTime < cooldown) {
4574
- return;
4575
- }
4576
- this.lastUpgrade = decision;
4577
- const quality = qpToQuality(qp);
4578
- const newTarget = this.getNextFpsTarget(this.currentFpsTarget, decision);
4579
- Logger.info(`[FpsMonitor] Decision: ${decision} → FPS: ${newTarget}, Quality: ${quality}`);
4580
- if (newTarget !== this.currentFpsTarget) {
4581
- this.currentFpsTarget = newTarget;
4582
- this.store.dispatch(setMaxFps({ maxFps: newTarget }));
4583
- }
4584
- this.lastDecisionTime = now;
4585
- this.samples = [];
4586
- }
4587
- /**
4588
- * Get the next FPS target based on current target and decision.
4589
- * Clamps to valid FPS_STEPS range.
4590
- */
4591
- getNextFpsTarget(currentTarget, decision) {
4592
- const currentIndex = this.FPS_STEPS.indexOf(currentTarget);
4593
- // If current target is not in FPS_STEPS, find closest one
4594
- const validIndex = currentIndex === -1
4595
- ? this.findClosestFpsIndex(currentTarget)
4596
- : currentIndex;
4597
- const delta = decision === 'increase' ? 1 : -1;
4598
- const newIndex = clampf(0, this.FPS_STEPS.length - 1, validIndex + delta);
4599
- return this.FPS_STEPS[newIndex];
4600
- }
4601
- /**
4602
- * Find the closest FPS step index for a given target value.
4603
- */
4604
- findClosestFpsIndex(target) {
4605
- let closestIndex = 0;
4606
- let closestDiff = Math.abs(this.FPS_STEPS[0] - target);
4607
- for (let i = 1; i < this.FPS_STEPS.length; i++) {
4608
- const diff = Math.abs(this.FPS_STEPS[i] - target);
4609
- if (diff < closestDiff) {
4610
- closestDiff = diff;
4611
- closestIndex = i;
4612
- }
4613
- }
4614
- return closestIndex;
4615
- }
4616
- /** Average all collected samples for the current evaluation window. */
4617
- averageSamples() {
4618
- const len = this.samples.length;
4619
- if (len === 0)
4620
- return { fps: 0, bitrate: 0, qp: 0 };
4621
- let fps = 0;
4622
- let bitrate = 0;
4623
- let qp = 0;
4624
- for (const s of this.samples) {
4625
- fps += s.fps;
4626
- bitrate += s.bitrate;
4627
- qp += s.qp;
4628
- }
4629
- return { fps: fps / len, bitrate: bitrate / len, qp: qp / len };
4630
- }
4631
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4632
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService }); }
4633
- }
4634
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService, decorators: [{
4635
- type: Injectable
4636
- }], ctorParameters: () => [] });
4637
-
4638
- class UnrealCallbackService {
4639
- constructor() {
4640
- this.actions$ = inject(Actions);
4641
- }
4642
- /**
4643
- * Listens for unreal command callbacks matching a specific command or event type.
4235
+ * A `profile-level-id` is a 3-byte hex string (`XXYYZZ`) defined in
4236
+ * RFC 6184 that encodes three H.264 parameters:
4237
+ * - **Profile IDC** (`XX`) — the H.264 profile.
4238
+ * - **Profile IOP** (`YY`) — constraint flags that further narrow the profile.
4239
+ * - **Level IDC** (`ZZ`) — the level, which caps resolution/framerate/bitrate.
4644
4240
  *
4645
- * Supports both MetaBoxCommand keys (e.g. `MetaBoxCommand.FLoadProductCommand`)
4646
- * and custom Unreal callback events (e.g. `'cameraChanged'`, `'onSceneState'`).
4241
+ * ### H.264 profiles relevant to Unreal Engine Pixel Streaming
4647
4242
  *
4648
- * @param command - The command or event key to filter callbacks by
4649
- * @returns Observable that emits matching callback payloads
4243
+ * | profile-level-id | Profile | Level | Notes |
4244
+ * |------------------|----------------------------|-------|----------------------------------------------|
4245
+ * | `42001f` | Baseline | 3.1 | No B-frames, low latency; legacy devices |
4246
+ * | `42e01f` | Constrained Baseline | 3.1 | Widest HW decode support; **recommended** |
4247
+ * | `4d001f` | Main | 3.1 | B-frames + CABAC; better compression |
4248
+ * | `4d4028` | Main | 4.0 | Up to 1080p60; common desktop target |
4249
+ * | `640028` | High | 4.0 | Best quality/compression; most GPUs support |
4250
+ * | `64002a` | High | 4.2 | Up to 1080p120 or 4K30 |
4251
+ * | `640033` | High | 5.1 | Up to 4K60; requires modern HW |
4252
+ * | `640034` | High | 5.2 | Up to 4K120; high-end HW only |
4253
+ * | `6e001f` | High 10 | 3.1 | 10-bit color; limited browser support |
4254
+ * | `f4001f` | High 4:4:4 Predictive | 3.1 | Lossless capable; rare in browsers |
4650
4255
  *
4651
- * @example
4652
- * ```ts
4653
- * // Listen for a MetaBox command callback
4654
- * this.unrealCallbackService
4655
- * .fromUnrealCallback(MetaBoxCommand.FLoadProductCommand)
4656
- * .subscribe(data => console.log(data));
4256
+ * UE Pixel Streaming defaults to Constrained Baseline (`42e01f`) or High (`6400xx`)
4257
+ * depending on engine version and `WebRTC.H264Profile` CVar.
4657
4258
  *
4658
- * // Listen for a custom Unreal event
4659
- * this.unrealCallbackService
4660
- * .fromUnrealCallback('cameraChanged')
4661
- * .subscribe(data => console.log(data));
4662
- * ```
4259
+ * We prioritize `42e01f` because it guarantees hardware decoding on
4260
+ * virtually all client devices (desktop, mobile, embedded), avoiding
4261
+ * fallback to software decoding or negotiation of a higher profile
4262
+ * that the client GPU may not accelerate.
4663
4263
  */
4664
- fromUnrealCallback(command) {
4665
- return this.actions$.pipe(ofType(unrealCommandCallback), map$1(({ json }) => {
4666
- if ('commandCallback' in json) {
4667
- return json.commandCallback;
4264
+ prioritizeH264Profile(sdp, profileLevelId) {
4265
+ const lines = sdp.split('\r\n');
4266
+ const targetPts = [];
4267
+ for (const line of lines) {
4268
+ if (line.startsWith('a=fmtp:') &&
4269
+ line.toLowerCase().includes(`profile-level-id=${profileLevelId}`)) {
4270
+ const pt = line.split(':')[1]?.split(' ')[0];
4271
+ if (pt)
4272
+ targetPts.push(pt);
4668
4273
  }
4669
- return json;
4670
- }), filter((json) => json.command === command), map$1((data) => ({
4671
- command: data.command,
4672
- payload: 'payload' in data ? data.payload : null,
4673
- })));
4274
+ }
4275
+ if (targetPts.length === 0)
4276
+ return sdp;
4277
+ return lines
4278
+ .map((line) => {
4279
+ if (!line.startsWith('m=video'))
4280
+ return line;
4281
+ const parts = line.split(' ');
4282
+ // parts: m=video <port> <proto> <pt1> <pt2> ...
4283
+ const header = parts.slice(0, 3);
4284
+ const pts = parts.slice(3);
4285
+ const prioritized = [
4286
+ ...targetPts.filter((pt) => pts.includes(pt)),
4287
+ ...pts.filter((pt) => !targetPts.includes(pt)),
4288
+ ];
4289
+ return [...header, ...prioritized].join(' ');
4290
+ })
4291
+ .join('\r\n');
4292
+ }
4293
+ mungeSDP(sdp) {
4294
+ let mungedSDP = this.prioritizeH264Profile(sdp, '42e01f');
4295
+ mungedSDP = mungedSDP.replace(/(a=fmtp:\d+ .*level-asymmetry-allowed=.*)\r\n/gm, '$1;x-google-start-bitrate=10000;x-google-max-bitrate=100000\r\n');
4296
+ // Set max bitrate to highest bitrate Opus supports
4297
+ let audioSDP = 'maxaveragebitrate=510000;';
4298
+ if (this.useMic) {
4299
+ // Set the max capture rate to 48khz (so we can send high-quality audio from mic)
4300
+ audioSDP += 'sprop-maxcapturerate=48000;';
4301
+ }
4302
+ // Force mono or stereo based on whether ?forceMono was passed or not
4303
+ audioSDP += this.forceMonoAudio ? 'stereo=0;' : 'stereo=1;';
4304
+ // Enable in-band forward error correction for opus audio
4305
+ audioSDP += 'useinbandfec=1';
4306
+ // We use the line 'useinbandfec=1' (which Opus uses) to set our Opus specific audio parameters.
4307
+ mungedSDP = mungedSDP?.replace('useinbandfec=1', audioSDP);
4308
+ return mungedSDP;
4674
4309
  }
4675
4310
  /**
4676
- * Sends a command and observes the matching response by correlationId.
4677
- * Replaces the old `observeCommandResponse` standalone function.
4678
- *
4679
- * @param data - The command data to send
4680
- * @param sender - Function to send the command to Unreal
4681
- * @param timeOut - Timeout in ms (default 60000)
4682
- * @param dispatchOnTimeout - Whether to emit on timeout (default true)
4683
- *
4684
- * @example
4685
- * ```ts
4686
- * this.unrealCallbackService
4687
- * .observeCommandResponse<FLoopBackCommand['command']>(
4688
- * { command: MetaBoxCommand.FLoopBackCommand },
4689
- * (data) => this.communicator.sendCommandToUnreal(data),
4690
- * )
4691
- * .subscribe();
4692
- * ```
4311
+ * Closes existing PeerConnection and DataChannel, resets negotiation state.
4693
4312
  */
4694
- observeCommandResponse(data, sender, timeOut = 60000, dispatchOnTimeout = true) {
4695
- const correlationId = generateUuid();
4696
- 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
4697
- ? of({ command: 'timeout', correlationId: 'timeout' })
4698
- : of(null)), take(1), tapLog('Command Response Observer'), filter(Truthy), map$1((data) => ({
4699
- command: data.command,
4700
- payload: 'payload' in data ? data.payload : null,
4701
- })));
4702
- setTimeout(() => sender({ ...data, correlationId }), 0);
4703
- return observable;
4704
- }
4705
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4706
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService }); }
4707
- }
4708
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService, decorators: [{
4709
- type: Injectable
4710
- }] });
4711
-
4712
- class AfkTimeoutModalComponent {
4713
- constructor() {
4714
- this.#store = inject(Store);
4715
- this.#afkService = inject(AFKService);
4716
- this.isAfkTimerVisible = this.#store.selectSignal(unrealFeature.selectIsAfkTimerVisible);
4717
- this.afkCountdown = this.#store.selectSignal(unrealFeature.selectAfkCountdown);
4718
- }
4719
- #store;
4720
- #afkService;
4721
- resetAfk() {
4722
- this.#afkService.reset();
4723
- }
4724
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkTimeoutModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4725
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: AfkTimeoutModalComponent, isStandalone: true, selector: "app-afk-timeout-modal", ngImport: i0, template: "@if (isAfkTimerVisible()) {\n <div (click)=\"resetAfk()\" class=\"clickableState\" id=\"videoPlayOverlay\">\n <div class=\"resume-box\">\n <div aria-hidden=\"true\" class=\"resume-box__pic\" role=\"presentation\">\n <div [innerHTML]=\"afkCountdown()\" class=\"text-number\"></div>\n </div>\n <div class=\"resume-box__text\">\n <h3 class=\"resume-box__heading\">Session will time out soon</h3>\n <p>\n No activity detected. Press 'Continue' if you wish to keep your\n session active\n </p>\n </div>\n <src-button\n [isFullWidth]=\"true\"\n [data-testid]=\"'continue-session'\"\n weight=\"primary\"\n size=\"lg\"\n >\n Continue\n </src-button>\n </div>\n </div>\n}\n", styles: ["#videoPlayOverlay{position:absolute;z-index:30;top:0;width:100%;height:100%;font-size:1.8em;font-family:var(--src-font-family-body);background-color:#646464b3}.clickableState{display:flex;justify-content:center;align-items:center;cursor:pointer}.textDisplayState{display:flex}.hiddenState{display:none}.resume-box{width:340px;padding:32px 20px 20px;flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .resume-box__pic{width:72px;height:72px;margin:0 auto 22px;border-radius:48px;background:#ecf0f2;padding:12px;display:flex;align-items:center;justify-content:center}.resume-box .resume-box__pic .text-number{color:var(--src-colors-text-default, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:30px;font-style:normal;font-weight:400;line-height:24px}.resume-box__text{margin-bottom:18px}.resume-box__text p{text-align:center;font-family:var(--src-font-family-body);font-size:var(--src-font-size-sm, 14px);font-style:normal;font-weight:400;line-height:24px;color:var(--src-text-body-secondary, #6b7280)}.resume-box__text .resume-box__heading{color:var(--src-text-body-main, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:18px;font-style:normal;font-weight:500;line-height:26px;margin-bottom:8px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4726
- }
4727
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkTimeoutModalComponent, decorators: [{
4728
- type: Component,
4729
- args: [{ selector: 'app-afk-timeout-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "@if (isAfkTimerVisible()) {\n <div (click)=\"resetAfk()\" class=\"clickableState\" id=\"videoPlayOverlay\">\n <div class=\"resume-box\">\n <div aria-hidden=\"true\" class=\"resume-box__pic\" role=\"presentation\">\n <div [innerHTML]=\"afkCountdown()\" class=\"text-number\"></div>\n </div>\n <div class=\"resume-box__text\">\n <h3 class=\"resume-box__heading\">Session will time out soon</h3>\n <p>\n No activity detected. Press 'Continue' if you wish to keep your\n session active\n </p>\n </div>\n <src-button\n [isFullWidth]=\"true\"\n [data-testid]=\"'continue-session'\"\n weight=\"primary\"\n size=\"lg\"\n >\n Continue\n </src-button>\n </div>\n </div>\n}\n", styles: ["#videoPlayOverlay{position:absolute;z-index:30;top:0;width:100%;height:100%;font-size:1.8em;font-family:var(--src-font-family-body);background-color:#646464b3}.clickableState{display:flex;justify-content:center;align-items:center;cursor:pointer}.textDisplayState{display:flex}.hiddenState{display:none}.resume-box{width:340px;padding:32px 20px 20px;flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .resume-box__pic{width:72px;height:72px;margin:0 auto 22px;border-radius:48px;background:#ecf0f2;padding:12px;display:flex;align-items:center;justify-content:center}.resume-box .resume-box__pic .text-number{color:var(--src-colors-text-default, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:30px;font-style:normal;font-weight:400;line-height:24px}.resume-box__text{margin-bottom:18px}.resume-box__text p{text-align:center;font-family:var(--src-font-family-body);font-size:var(--src-font-size-sm, 14px);font-style:normal;font-weight:400;line-height:24px;color:var(--src-text-body-secondary, #6b7280)}.resume-box__text .resume-box__heading{color:var(--src-text-body-main, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:18px;font-style:normal;font-weight:500;line-height:26px;margin-bottom:8px}\n"] }]
4730
- }] });
4731
-
4732
- class FreezeFrameComponent {
4733
- constructor() {
4734
- this.#store = inject(Store);
4735
- this.freezeFrameProgressMessageFromVideo = this.#store.selectSignal(selectFreezeFrameProgressMessageFromVideo);
4736
- this.combinedFreeze = this.#store.selectSignal(selectFreezeFrameCombinedDataUrl);
4737
- }
4738
- #store;
4739
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFrameComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4740
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: FreezeFrameComponent, isStandalone: true, selector: "app-freeze-frame", ngImport: i0, template: "@let image = combinedFreeze();\n@let progress = freezeFrameProgressMessageFromVideo();\n\n<div class=\"freeze-images\">\n @if (image) {\n <img [src]=\"image\" class=\"videoStillImage\" alt=\"freezeFrameImage\" />\n }\n\n @if (progress) {\n <div class=\"progress-status\">\n {{ progress }}\n </div>\n }\n</div>\n", styles: [".freeze-images{pointer-events:none;position:absolute;left:0;top:0;width:100%;height:100%;z-index:1}.freeze-images .freezeFrameOverlay{top:0;left:0;width:100%;height:100%;position:absolute;object-fit:cover;object-position:center}.freeze-images .videoStillImage{position:absolute;z-index:1;top:0;left:0;width:100%;height:100%;object-fit:cover}.freeze-images .progress-status{position:absolute;top:50%;left:50%;z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:auto;height:auto;margin:0;padding:10px;background-color:#ffffffb3;border:1px solid grey;transform:translate(-50%,-50%)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4741
- }
4742
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFrameComponent, decorators: [{
4743
- type: Component,
4744
- args: [{ selector: 'app-freeze-frame', changeDetection: ChangeDetectionStrategy.OnPush, template: "@let image = combinedFreeze();\n@let progress = freezeFrameProgressMessageFromVideo();\n\n<div class=\"freeze-images\">\n @if (image) {\n <img [src]=\"image\" class=\"videoStillImage\" alt=\"freezeFrameImage\" />\n }\n\n @if (progress) {\n <div class=\"progress-status\">\n {{ progress }}\n </div>\n }\n</div>\n", styles: [".freeze-images{pointer-events:none;position:absolute;left:0;top:0;width:100%;height:100%;z-index:1}.freeze-images .freezeFrameOverlay{top:0;left:0;width:100%;height:100%;position:absolute;object-fit:cover;object-position:center}.freeze-images .videoStillImage{position:absolute;z-index:1;top:0;left:0;width:100%;height:100%;object-fit:cover}.freeze-images .progress-status{position:absolute;top:50%;left:50%;z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:auto;height:auto;margin:0;padding:10px;background-color:#ffffffb3;border:1px solid grey;transform:translate(-50%,-50%)}\n"] }]
4745
- }] });
4746
-
4747
- class SafePipe {
4748
- constructor() {
4749
- this.sanitizer = inject(DomSanitizer);
4750
- }
4751
- transform(value, type) {
4752
- if (!value) {
4753
- return value;
4313
+ closePC() {
4314
+ if (this.pcClient) {
4315
+ Logger.warn('Closing existing PeerConnection');
4754
4316
  }
4755
- switch (type) {
4756
- case 'html':
4757
- return this.sanitizer.bypassSecurityTrustHtml(value);
4758
- case 'style':
4759
- return this.sanitizer.bypassSecurityTrustStyle(value);
4760
- case 'script':
4761
- return this.sanitizer.bypassSecurityTrustScript(value);
4762
- case 'url':
4763
- return this.sanitizer.bypassSecurityTrustUrl(value);
4764
- case 'resourceUrl':
4765
- return this.sanitizer.bypassSecurityTrustResourceUrl(value);
4766
- default:
4767
- throw new Error(`Invalid safe type specified: ${type}`);
4317
+ this.pcClient?.close();
4318
+ this.pcClient = null;
4319
+ if (this.dcClient) {
4320
+ Logger.warn('Closing existing DataChannel');
4768
4321
  }
4322
+ this.dcClient?.close();
4323
+ this.dcClient = null;
4324
+ this.remoteDescriptionSet = false;
4325
+ this.iceCandidateBuffer.length = 0;
4769
4326
  }
4770
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
4771
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, isStandalone: true, name: "safe" }); }
4772
- }
4773
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, decorators: [{
4774
- type: Pipe,
4775
- args: [{
4776
- name: 'safe',
4777
- }]
4778
- }] });
4779
-
4780
- class LowBandwidthModalComponent {
4781
- constructor() {
4782
- this.dialogRef = inject(DialogRef);
4783
- }
4784
- close(value) {
4785
- this.dialogRef.close(value || false);
4786
- }
4787
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4788
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: LowBandwidthModalComponent, isStandalone: true, selector: "app-low-bandwidth-modal", ngImport: i0, template: "<div [attr.data-testid]=\"'low-bandwidth'\" class=\"src-modal src-modal--lbm\">\n <header class=\"src-modal__header\">\n <h6 [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n Unstable Connection\n </h6>\n </header>\n <section class=\"src-modal__body\">\n <div\n [innerHtml]=\"\n 'Fluid Interactivity Modes were disabled due to an unstable connection. Showcase Gallery Mode is enabled. To regain full functionality, switch to Interactive Mode.'\n | safe: 'html'\n \"\n class=\"src-modal__scroll-box\"\n ></div>\n </section>\n <footer class=\"src-modal__footer\">\n <div class=\"src-modal__buttons\">\n <src-button\n (onClick)=\"close(true)\"\n [data-testid]=\"'switch-to-interactive-mode'\"\n >\n Switch\n </src-button>\n\n <src-button\n (onClick)=\"close()\"\n [data-testid]=\"'close-lbm-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </div>\n </footer>\n</div>\n", styles: [".src-modal--lbm{width:360px}.src-modal--lbm .src-modal__body{font-size:14px;line-height:24px}.src-modal--lbm .src-modal__buttons{display:flex;gap:8px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }, { kind: "pipe", type: SafePipe, name: "safe" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4789
- }
4790
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthModalComponent, decorators: [{
4791
- type: Component,
4792
- args: [{ selector: 'app-low-bandwidth-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SafePipe, SourceButtonComponent], template: "<div [attr.data-testid]=\"'low-bandwidth'\" class=\"src-modal src-modal--lbm\">\n <header class=\"src-modal__header\">\n <h6 [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n Unstable Connection\n </h6>\n </header>\n <section class=\"src-modal__body\">\n <div\n [innerHtml]=\"\n 'Fluid Interactivity Modes were disabled due to an unstable connection. Showcase Gallery Mode is enabled. To regain full functionality, switch to Interactive Mode.'\n | safe: 'html'\n \"\n class=\"src-modal__scroll-box\"\n ></div>\n </section>\n <footer class=\"src-modal__footer\">\n <div class=\"src-modal__buttons\">\n <src-button\n (onClick)=\"close(true)\"\n [data-testid]=\"'switch-to-interactive-mode'\"\n >\n Switch\n </src-button>\n\n <src-button\n (onClick)=\"close()\"\n [data-testid]=\"'close-lbm-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </div>\n </footer>\n</div>\n", styles: [".src-modal--lbm{width:360px}.src-modal--lbm .src-modal__body{font-size:14px;line-height:24px}.src-modal--lbm .src-modal__buttons{display:flex;gap:8px}\n"] }]
4793
- }] });
4794
-
4795
- class ImageLoadingSrcComponent {
4796
- constructor() {
4797
- this.#store = inject(Store);
4798
- this.isLoaderScreenVisible = this.#store.selectSignal(selectIsLoaderScreenVisible);
4799
- this.imageLoadingSrc = this.#store.selectSignal(unrealFeature.selectImageLoadingSrc);
4800
- }
4801
- #store;
4802
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageLoadingSrcComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4803
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: ImageLoadingSrcComponent, isStandalone: true, selector: "app-image-loading-src", ngImport: i0, template: "@let image = imageLoadingSrc();\n@if (isLoaderScreenVisible()) {\n <div class=\"preload\">\n <div class=\"back\">\n @if (image) {\n <img [ngSrc]=\"image\" fill loading=\"lazy\" alt=\"image loading src\" />\n }\n </div>\n </div>\n}\n", styles: [".preload{position:absolute;top:0;left:0;width:100%;height:100%;text-align:center}.preload .back{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%}.preload .back img{width:100%;height:100%;object-fit:cover}.preload .back{background-color:#fff}\n"], dependencies: [{ kind: "directive", type: NgOptimizedImage, selector: "img[ngSrc]", inputs: ["ngSrc", "ngSrcset", "sizes", "width", "height", "decoding", "loading", "priority", "loaderParams", "disableOptimizedSrcset", "fill", "placeholder", "placeholderConfig", "src", "srcset"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4804
- }
4805
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageLoadingSrcComponent, decorators: [{
4806
- type: Component,
4807
- args: [{ selector: 'app-image-loading-src', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgOptimizedImage], template: "@let image = imageLoadingSrc();\n@if (isLoaderScreenVisible()) {\n <div class=\"preload\">\n <div class=\"back\">\n @if (image) {\n <img [ngSrc]=\"image\" fill loading=\"lazy\" alt=\"image loading src\" />\n }\n </div>\n </div>\n}\n", styles: [".preload{position:absolute;top:0;left:0;width:100%;height:100%;text-align:center}.preload .back{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%}.preload .back img{width:100%;height:100%;object-fit:cover}.preload .back{background-color:#fff}\n"] }]
4808
- }] });
4809
-
4810
- class StatGraphComponent {
4811
- constructor() {
4812
- this.label = input.required(...(ngDevMode ? [{ debugName: "label" }] : []));
4813
- this.stat = input(...(ngDevMode ? [undefined, { debugName: "stat" }] : []));
4814
- this.color = input('#417bc9', ...(ngDevMode ? [{ debugName: "color" }] : []));
4815
- this.tickStep = input(3, ...(ngDevMode ? [{ debugName: "tickStep" }] : []));
4816
- this.el = inject(ElementRef);
4817
- this.graph = viewChild('graph', ...(ngDevMode ? [{ debugName: "graph" }] : []));
4818
- this.data = [];
4819
- this.max = signal(0, ...(ngDevMode ? [{ debugName: "max" }] : []));
4820
- this.min = signal(0, ...(ngDevMode ? [{ debugName: "min" }] : []));
4821
- this.current = signal(0, ...(ngDevMode ? [{ debugName: "current" }] : []));
4822
- effect(() => this.pushData(this.stat()?.value ?? null));
4823
- afterNextRender({
4824
- read: () => {
4825
- const graph = this.graph();
4826
- if (graph) {
4827
- this.canvas = graph.nativeElement;
4828
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4829
- this.ctx = this.canvas.getContext('2d');
4830
- }
4831
- },
4832
- });
4327
+ canSend() {
4328
+ return this.dcClient && this.dcClient.readyState === 'open';
4833
4329
  }
4834
- resize() {
4835
- this.draw();
4330
+ send(data) {
4331
+ if (this.canSend()) {
4332
+ this.dcClient?.send(data);
4333
+ }
4334
+ else {
4335
+ Logger.colored('black', 'yellow', `DataChannel connection not prepared or in state: ${this.dcClient?.readyState}`);
4336
+ // Trigger peer reconnection if DC is not open and we're not already reconnecting
4337
+ this.tryReconnectOnSendFailure();
4338
+ }
4836
4339
  }
4837
- draw() {
4838
- if (!this.canvas) {
4340
+ /**
4341
+ * Attempts to reconnect the peer when send fails due to DataChannel not being open.
4342
+ * This handles cases where the DC connection is broken but no error events are fired.
4343
+ */
4344
+ tryReconnectOnSendFailure() {
4345
+ // Skip if already reconnecting or signaling is not connected
4346
+ if (this.isReconnecting()) {
4347
+ Logger.info('[Reconnect] Already reconnecting, skipping duplicate attempt');
4348
+ return;
4349
+ }
4350
+ if (!this.signallingSrv.isConnected()) {
4351
+ Logger.warn('[Reconnect] Cannot reconnect: Signaling not connected');
4839
4352
  return;
4840
4353
  }
4841
- const all = this.data.filter((r) => r !== null && !isNaN(r));
4842
- this.max.set(Math.max(...all));
4843
- this.min.set(Math.min(...all));
4844
- const scaleY = this.canvas.height / (this.max() || 1);
4845
- this.current.set(all[all.length - 1] || 0);
4846
- this.ctx.save();
4847
- const bounds = this.el.nativeElement.getBoundingClientRect();
4848
- this.canvas.width = bounds.width;
4849
- this.canvas.height = bounds.height - 30; // -p height
4850
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
4851
- [...this.data].reverse().forEach((val, index) => {
4852
- const h = (val || 0) * scaleY;
4853
- this.ctx.fillStyle = this.getColor(this.color(), h);
4854
- this.ctx.fillRect(index * this.tickStep(), this.canvas.height - h, this.tickStep(), h);
4855
- });
4856
- this.ctx.restore();
4354
+ // Check if DC exists but is in a non-open state (closing, closed, or connecting stuck)
4355
+ const dcState = this.dcClient?.readyState;
4356
+ if (dcState && dcState !== 'open') {
4357
+ Logger.warn(`[Reconnect] DataChannel in "${dcState}" state during send, initiating peer reconnection`);
4358
+ this.store.dispatch(reconnectPeer({ attempt: 1, maxAttempts: 3, delay: 0 }));
4359
+ }
4857
4360
  }
4858
- getColor(fillStyle, value = 0) {
4859
- const { r, g, b } = HEXtoRGB(fillStyle);
4860
- /* eslint-disable-next-line prefer-const */
4861
- let [h, s, v] = RGBtoHSV(r, g, b);
4862
- v = inverseLerp(0, this.canvas.height, value);
4863
- {
4864
- const [r, g, b] = HSVtoRGB(h, s, v);
4865
- return '#' + RGBtoHEX(r, g, b);
4361
+ /**
4362
+ * Reconnects only the WebRTC peer connection without re-establishing signaling.
4363
+ * Closes the current PeerConnection and creates a new client-initiated offer
4364
+ * through the existing WebSocket connection.
4365
+ * @returns true if reconnection was initiated, false if signaling is not connected
4366
+ */
4367
+ reconnectPeer() {
4368
+ if (!this.signallingSrv.isConnected()) {
4369
+ Logger.error('[Reconnect] Cannot reconnect peer: Signaling not connected');
4370
+ return false;
4866
4371
  }
4372
+ if (!this.cfg || Object.keys(this.cfg).length === 0) {
4373
+ Logger.error('[Reconnect] Cannot reconnect peer: No config available');
4374
+ return false;
4375
+ }
4376
+ Logger.info('[Reconnect] Initiating peer reconnection');
4377
+ // Close only the PeerConnection, keep WebSocket alive
4378
+ this.closePC();
4379
+ // Create a new WebRTC offer using the stored config
4380
+ void this.createWebRtcOffer();
4381
+ return true;
4867
4382
  }
4868
- pushData(stat) {
4869
- this.data.push(stat);
4870
- if (!this.canvas) {
4871
- return;
4383
+ setConfig(parOptions = {}) {
4384
+ const urlParams = new URLSearchParams(window.location.search);
4385
+ //**********************
4386
+ //Config setup
4387
+ //**********************
4388
+ this.cfg = { ...parOptions.peerConnectionOptions };
4389
+ this.forceTURN = urlParams.has('ForceTURN');
4390
+ if (this.forceTURN) {
4391
+ Logger.info('Forcing TURN usage by setting ICE Transport Policy in peer connection config.');
4392
+ this.cfg.iceTransportPolicy = 'relay';
4872
4393
  }
4873
- if (this.data.length > this.canvas.width / this.tickStep()) {
4874
- this.data.shift();
4394
+ this.cfg.bundlePolicy = urlParams.has('ForceMaxBundle')
4395
+ ? 'max-bundle'
4396
+ : 'balanced';
4397
+ this.cfg.iceCandidatePoolSize = 1;
4398
+ // When ?useMic check for SSL or localhost
4399
+ const isLocalhostConnection = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
4400
+ const isHttpsConnection = location.protocol === 'https:';
4401
+ if (this.useMic && !isLocalhostConnection && !isHttpsConnection) {
4402
+ this.useMic = false;
4403
+ Logger.error('Microphone access in the browser will not work if you are not on HTTPS or localhost. Disabling mic access.');
4404
+ Logger.error("For testing you can enable HTTP microphone access Chrome by visiting chrome://flags/ and enabling 'unsafely-treat-insecure-origin-as-secure'");
4875
4405
  }
4876
- this.draw();
4406
+ this.setupPeerConnection(this.cfg);
4877
4407
  }
4878
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StatGraphComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4879
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.0", type: StatGraphComponent, isStandalone: true, selector: "app-stat-graph", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, stat: { classPropertyName: "stat", publicName: "stat", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, tickStep: { classPropertyName: "tickStep", publicName: "tickStep", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "window:resize": "resize()" } }, viewQueries: [{ propertyName: "graph", first: true, predicate: ["graph"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"content\">\n <p>{{ label() }}: {{ current() }} | Min:{{ min() }} | Max:{{ max() }}</p>\n <canvas #graph class=\"graph\"></canvas>\n</div>\n", styles: [".content{width:100%;height:100%}.content p{color:#fff;margin:5px 0 0;height:25px}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4880
- }
4881
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StatGraphComponent, decorators: [{
4882
- type: Component,
4883
- args: [{ selector: 'app-stat-graph', changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"content\">\n <p>{{ label() }}: {{ current() }} | Min:{{ min() }} | Max:{{ max() }}</p>\n <canvas #graph class=\"graph\"></canvas>\n</div>\n", styles: [".content{width:100%;height:100%}.content p{color:#fff;margin:5px 0 0;height:25px}\n"] }]
4884
- }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], stat: [{ type: i0.Input, args: [{ isSignal: true, alias: "stat", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], tickStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "tickStep", required: false }] }], graph: [{ type: i0.ViewChild, args: ['graph', { isSignal: true }] }], resize: [{
4885
- type: HostListener,
4886
- args: ['window:resize']
4887
- }] } });
4888
-
4889
- class UnrealStatusComponent {
4890
- constructor() {
4891
- this.unrealInitialConfig = inject(UNREAL_CONFIG);
4892
- this.destroyRef = inject(DestroyRef);
4893
- this.store = inject(Store);
4894
- this.resetPercentageSignal$ = this.store
4895
- .select(selectShowLoader)
4896
- .pipe(filter(Falsy));
4897
- this.showLoader = this.store.selectSignal(selectShowLoader);
4898
- /**
4899
- * An observable that emits smoothed percentage values from 0 to 100.
4900
- */
4901
- this.messagePercents = toSignal(this.store.select(selectTotalProgress).pipe(keepMaxUntilReset(this.resetPercentageSignal$), floatToSmoothPercents(), distinctUntilChanged(), map$1((progress) => progress || 0)));
4902
- this.awsInstance = this.store.selectSignal(unrealFeature.selectAwsInstance);
4408
+ handlePeerConnectionEvents(pcClient) {
4409
+ fromEvent(pcClient, 'signalingstatechange')
4410
+ .pipe(map$1(() => this.pcClient?.signalingState), takeUntil(this.disconnect$))
4411
+ .subscribe((state) => {
4412
+ Logger.info('[Signaling state change] |', state, '|');
4413
+ this.commandTelemetryService.trackStopCommand('EXT-Signaling state change', true, { state, multi: true });
4414
+ });
4415
+ fromEvent(pcClient, 'iceconnectionstatechange')
4416
+ .pipe(map$1(() => this.pcClient?.iceConnectionState), takeUntil(this.disconnect$))
4417
+ .subscribe((state) => {
4418
+ Logger.info('[Browser ICE connection] |', state, '|');
4419
+ this.commandTelemetryService.trackStopCommand('EXT-Browser ICE connection', true, { state, multi: true });
4420
+ });
4421
+ fromEvent(pcClient, 'icegatheringstatechange')
4422
+ .pipe(map$1(() => this.pcClient?.iceGatheringState), takeUntil(this.disconnect$))
4423
+ .subscribe((state) => {
4424
+ Logger.info('[Browser ICE gathering] |', state, '|');
4425
+ this.commandTelemetryService.trackStopCommand('EXT-Browser ICE gathering', true, { state, multi: true });
4426
+ });
4427
+ fromEvent(pcClient, 'connectionstatechange')
4428
+ .pipe(map$1(() => this.pcClient?.connectionState), takeUntil(this.disconnect$))
4429
+ .subscribe((state) => {
4430
+ Logger.info('[RTCPeerConnection state] |', state, '|');
4431
+ });
4432
+ fromEvent(pcClient, 'icecandidate')
4433
+ .pipe(map$1((e) => e.candidate), filter(Truthy), tap$1((candidate) => Logger.info('[Browser ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|')), takeUntil(this.disconnect$))
4434
+ .subscribe((candidate) => this.signallingSrv.send({ type: 'iceCandidate', candidate }));
4435
+ // When the server creates the DataChannel (server-offer flow),
4436
+ // we receive it here via the 'datachannel' event
4437
+ fromEvent(pcClient, 'datachannel')
4438
+ .pipe(tap$1((e) => Logger.info(`[DATACHANNEL] Received remote DC: label="${e.channel.label}" id=${e.channel.id} protocol="${e.channel.protocol}" ordered=${e.channel.ordered}`)), map$1((e) => e.channel), takeUntil(this.disconnect$))
4439
+ .subscribe((datachannel) => {
4440
+ this.dcClient = this.setupDataChannelCallbacks(datachannel);
4441
+ });
4442
+ const onTrack$ = fromEvent(pcClient, 'track').pipe(takeUntil(this.disconnect$), filter((e) => !!e.track), tap$1((e) => Logger.info('Got track - ' +
4443
+ e.track.kind +
4444
+ ' id=' +
4445
+ e.track.id +
4446
+ ' readyState=' +
4447
+ e.track.readyState)), share());
4448
+ onTrack$
4449
+ .pipe(filter((e) => e.track.kind === 'audio'), map$1((e) => e.streams[0]))
4450
+ .subscribe((stream) => this.videoService.attachAudioStream(stream));
4451
+ onTrack$
4452
+ .pipe(filter((e) => e.track.kind === 'video'), map$1((e) => e.streams[0]), tap$1(() => Logger.info('Set video source from video track ontrack.')))
4453
+ .subscribe((stream) => {
4454
+ if (this.pcClient) {
4455
+ this.videoService.attachVideoStream(stream, this.pcClient);
4456
+ }
4457
+ });
4458
+ }
4459
+ async setupTransceiversAsync(pc) {
4460
+ const hasTransceivers = pc.getTransceivers().length > 0;
4461
+ // Set up a transceiver for getting UE video
4462
+ pc.addTransceiver('video', { direction: 'recvonly' });
4463
+ // Set up a transceiver for sending mic audio to UE and receiving audio from UE
4464
+ if (!this.useMic) {
4465
+ pc.addTransceiver('audio', { direction: 'recvonly' });
4466
+ }
4467
+ else {
4468
+ const audioSendOptions = this.useMic
4469
+ ? {
4470
+ autoGainControl: false,
4471
+ channelCount: 1,
4472
+ echoCancellation: false,
4473
+ latency: 0,
4474
+ noiseSuppression: false,
4475
+ sampleRate: 48000,
4476
+ sampleSize: 16,
4477
+ volume: 1.0,
4478
+ }
4479
+ : false;
4480
+ // Note using mic on android chrome requires SSL or chrome://flags/ "unsafely-treat-insecure-origin-as-secure"
4481
+ const stream = await navigator.mediaDevices.getUserMedia({
4482
+ video: false,
4483
+ audio: audioSendOptions,
4484
+ });
4485
+ if (stream) {
4486
+ if (hasTransceivers) {
4487
+ for (const transceiver of pc.getTransceivers()) {
4488
+ if (transceiver &&
4489
+ transceiver.receiver &&
4490
+ transceiver.receiver.track &&
4491
+ transceiver.receiver.track.kind === 'audio') {
4492
+ for (const track of stream.getTracks()) {
4493
+ if (track.kind && track.kind === 'audio') {
4494
+ await transceiver.sender.replaceTrack(track);
4495
+ transceiver.direction = 'sendrecv';
4496
+ }
4497
+ }
4498
+ }
4499
+ }
4500
+ }
4501
+ else {
4502
+ for (const track of stream.getTracks()) {
4503
+ if (track.kind && track.kind === 'audio') {
4504
+ pc.addTransceiver(track, { direction: 'sendrecv' });
4505
+ }
4506
+ }
4507
+ }
4508
+ }
4509
+ else {
4510
+ pc.addTransceiver('audio', { direction: 'recvonly' });
4511
+ }
4512
+ }
4513
+ }
4514
+ setupDataChannelCallbacks(datachannel) {
4515
+ try {
4516
+ Logger.log(`[DATACHANNEL] Setting up datachannel "${datachannel.label}" callbacks...`);
4517
+ this.commandTelemetryService.trackStartCommand('EXT-DataChannel Connection');
4518
+ // Inform browser we would like binary data as an ArrayBuffer (FF chooses Blob by default!)
4519
+ datachannel.binaryType = 'arraybuffer';
4520
+ fromEvent(datachannel, 'close')
4521
+ .pipe(
4522
+ // Only dispatch disconnect if not already reconnecting to avoid infinite loops
4523
+ filter(() => !this.isReconnecting()), take(1))
4524
+ .subscribe((e) => {
4525
+ Logger.warn(`[DATACHANNEL] Data channel disconnected: ${datachannel.label}(${datachannel.id})`, e);
4526
+ this.store.dispatch(disconnectStream({
4527
+ reason: DisconnectReason.DataChannelClosed,
4528
+ message: 'DataChannelClosed',
4529
+ }));
4530
+ });
4531
+ fromEvent(datachannel, 'error')
4532
+ .pipe(takeUntil(this.disconnect$))
4533
+ .subscribe((e) => {
4534
+ const message = `[DATACHANNEL] Data channel "${datachannel.label}" error. id:${datachannel.id}`;
4535
+ Logger.error(message, e);
4536
+ });
4537
+ fromEvent(datachannel, 'open')
4538
+ .pipe(tap$1(() => {
4539
+ Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
4540
+ }), takeUntil(this.disconnect$))
4541
+ .subscribe(() => {
4542
+ this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
4543
+ this.onDataChannelConnected();
4544
+ });
4545
+ fromEvent(datachannel, 'message')
4546
+ .pipe(
4547
+ /* tap((e: MessageEvent) => {
4548
+ const view = new Uint8Array(e.data);
4549
+ Logger.info(
4550
+ `[DATACHANNEL] Message received on "${datachannel.label}" id=${datachannel.id}, byteCode=${view[0]}, size=${view.byteLength}`,
4551
+ );
4552
+ }),*/
4553
+ map$1((e) => e.data), takeUntil(this.disconnect$))
4554
+ .subscribe((data) => this.onDataChannelMessage$.next(data));
4555
+ return datachannel;
4556
+ }
4557
+ catch (e) {
4558
+ Logger.warn('[DATACHANNEL] Datachannel setup caused an exception: ', e);
4559
+ return null;
4560
+ }
4903
4561
  }
4904
- ngOnInit() {
4905
- /**
4906
- * A subject that emits null values to trigger nullification.
4907
- */
4908
- const nullify$ = new Subject();
4909
- /**
4910
- * An observable that emits null values with a delay of 2000 milliseconds.
4911
- */
4912
- const nullifyObs$ = nullify$
4913
- .asObservable()
4914
- .pipe(delay(2000));
4915
- /**
4916
- * An observable that emits the status message from the Unreal feature store.
4917
- */
4918
- const signalingStatus$ = this.store.select(unrealFeature.selectStatusMessage);
4919
- /**
4920
- * An observable that emits connection status messages based on the Cirrus connection state.
4921
- * Then call side effect to nullify the message.
4922
- */
4923
- const cirrusConnected$ = this.store
4924
- .select(unrealFeature.selectCirrusConnected)
4925
- .pipe(skip(1), switchMap((connected) => connected
4926
- ? of('Creating Socket...')
4927
- : of('Connection closed.').pipe(tap(() => nullify$.next(null)))));
4928
- /**
4929
- * An observable that emits the viewport visibility state.
4930
- */
4931
- const viewportTrigger$ = this.store.select(unrealFeature.selectViewportReady);
4932
- /**
4933
- * An observable that will nullify any message displayed after 500 mSec, when viewport changes its state.
4934
- */
4935
- const streamToggle$ = merge(viewportTrigger$.pipe(filter(Falsy), skip(1)), viewportTrigger$.pipe(filter(Truthy))).pipe(delay(500), map$1(() => null));
4936
- /**
4937
- * A subscription that merges various status observables and emits debounced status via postMessage.
4938
- */
4939
- merge(nullifyObs$, cirrusConnected$, signalingStatus$, streamToggle$)
4940
- .pipe(debounceTime$1(0), tapLog('STATUS=>'), takeUntilDestroyed(this.destroyRef))
4941
- .subscribe((status) => this.sendMessageToIndex(status));
4562
+ onDataChannelConnected() {
4563
+ let statusMessage = 'Session connected, video stream starting.';
4564
+ if (navigator.userAgent.includes('Firefox')) {
4565
+ statusMessage +=
4566
+ '\n If you are experiencing connection problems please try Google Chrome';
4567
+ }
4568
+ this.store.dispatch(dataChannelConnected({ statusMessage }));
4942
4569
  }
4943
4570
  /**
4944
- * Sends a message to the embedded iframe identified by the default value '3dsource_start_screen' ID.
4945
- * This function tries to post a message to the iframe's content window. If the content
4946
- * window is not accessible (e.g., due to cross-origin policies), the function fails silently.
4947
- *
4948
- * @param {string} text The message to be sent to the iframe. This could be a status update
4949
- * or any other relevant information that the iframe needs to process.
4571
+ * Creates an SDP offer, applies SDP munging, sets it as local description,
4572
+ * and sends it to the server via signaling.
4573
+ * Transceivers already configure media directions, so deprecated
4574
+ * offerToReceive* options are not used.
4950
4575
  */
4951
- sendMessageToIndex(text) {
4952
- const objectElement = document.getElementById(this.unrealInitialConfig?.screenLockerContainerId ||
4953
- SCREEN_LOCKER_CONTAINER_ID);
4576
+ async createOffer(pcClient) {
4954
4577
  try {
4955
- const objectDocument = objectElement.contentWindow;
4956
- objectDocument?.postMessage(text, '*');
4578
+ const offer = await pcClient.createOffer();
4579
+ offer.sdp = this.mungeSDP(offer.sdp ?? '');
4580
+ await pcClient.setLocalDescription(offer);
4581
+ this.signallingSrv.send(offer);
4957
4582
  }
4958
4583
  catch {
4959
- // Fails silently if the iframe's content window cannot be accessed
4584
+ Logger.error("Couldn't create offer");
4960
4585
  }
4961
4586
  }
4962
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealStatusComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4963
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: UnrealStatusComponent, isStandalone: true, selector: "app-unreal-status", ngImport: i0, template: "@if (showLoader()) {\n <div class=\"status-box\">\n @if (awsInstance()?.message) {\n <p>{{ awsInstance()?.message }}</p>\n } @else {\n <div class=\"status-box__message\">\n <src-loading\n [size]=\"30\"\n [backgroundStrokeColor]=\"'#E5E7EB'\"\n [progressStrokeColor]=\"'#1F2937'\"\n />\n {{ messagePercents() }}%\n </div>\n }\n </div>\n}\n", styles: ["app-stat-graph{height:30px;width:100%}.status-box{position:absolute;top:80%;left:50%;transform:translate(-50%,-50%);z-index:5;display:flex;justify-content:center;align-items:center;width:auto;max-width:500px;margin:0;background:var(--src-surface-container-main, #fff);border:none;border-radius:var(--src-border-rounded-full, 9999px);box-shadow:0 8px 20px #1718181f,0 3px 6px #17181814;pointer-events:none}.status-box p{padding:10px 15px;margin:0;text-align:center}.status-box__message{display:flex;align-items:center;padding:12px 20px 12px 16px;color:var(--src-text-body-main, #1f2937);font-size:16px;font-weight:400;line-height:24px}.status-box__message .status-box__message_text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.status-box__message src-loading{margin-right:12px}\n"], dependencies: [{ kind: "component", type: SourceLoadingComponent, selector: "src-loading", inputs: ["size", "progress", "lineCap", "backgroundStrokeColor", "progressStrokeColor", "strokeWidth", "data-testid", "role", "ariaLabel", "ariaValueNow", "ariaValueMin", "ariaValueMax"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4587
+ /**
4588
+ * Creates an SDP answer, applies SDP munging, sets it as local description,
4589
+ * and sends it to the server via signaling.
4590
+ */
4591
+ async createAnswer(pcClient) {
4592
+ try {
4593
+ const answer = await pcClient.createAnswer();
4594
+ answer.sdp = this.mungeSDP(answer.sdp ?? '');
4595
+ await pcClient.setLocalDescription(answer);
4596
+ this.signallingSrv.send(answer);
4597
+ }
4598
+ catch (e) {
4599
+ Logger.error("Couldn't create answer", e);
4600
+ }
4601
+ }
4602
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4603
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2 }); }
4964
4604
  }
4965
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealStatusComponent, decorators: [{
4966
- type: Component,
4967
- args: [{ selector: 'app-unreal-status', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceLoadingComponent], template: "@if (showLoader()) {\n <div class=\"status-box\">\n @if (awsInstance()?.message) {\n <p>{{ awsInstance()?.message }}</p>\n } @else {\n <div class=\"status-box__message\">\n <src-loading\n [size]=\"30\"\n [backgroundStrokeColor]=\"'#E5E7EB'\"\n [progressStrokeColor]=\"'#1F2937'\"\n />\n {{ messagePercents() }}%\n </div>\n }\n </div>\n}\n", styles: ["app-stat-graph{height:30px;width:100%}.status-box{position:absolute;top:80%;left:50%;transform:translate(-50%,-50%);z-index:5;display:flex;justify-content:center;align-items:center;width:auto;max-width:500px;margin:0;background:var(--src-surface-container-main, #fff);border:none;border-radius:var(--src-border-rounded-full, 9999px);box-shadow:0 8px 20px #1718181f,0 3px 6px #17181814;pointer-events:none}.status-box p{padding:10px 15px;margin:0;text-align:center}.status-box__message{display:flex;align-items:center;padding:12px 20px 12px 16px;color:var(--src-text-body-main, #1f2937);font-size:16px;font-weight:400;line-height:24px}.status-box__message .status-box__message_text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.status-box__message src-loading{margin-right:12px}\n"] }]
4605
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2, decorators: [{
4606
+ type: Injectable
4607
+ }], ctorParameters: () => [] });
4608
+
4609
+ class AfkPlaywrightService extends AFKService {
4610
+ init() {
4611
+ return;
4612
+ }
4613
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4614
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService }); }
4615
+ }
4616
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkPlaywrightService, decorators: [{
4617
+ type: Injectable
4968
4618
  }] });
4969
4619
 
4970
- class VideoStatsComponent {
4620
+ class SignallingPlaywrightService extends SignallingService {
4971
4621
  constructor() {
4972
- this.collapsed = signal(false, ...(ngDevMode ? [{ debugName: "collapsed" }] : []));
4973
- this.store = inject(Store);
4974
- this.videoService = inject(VideoService);
4975
- this.isDevMode = inject(DevModeService).isDevMode;
4976
- this.obs$ = this.videoService.videoStats$;
4977
- this.viewportReady = this.store.selectSignal(unrealFeature.selectViewportReady);
4978
- this.ssInfo = this.store.selectSignal(selectSignalingParameters);
4979
- this.fpsTick = toSignal(this.obs$.pipe(map$1((stat) => ({
4980
- value: stat.aggregatedStats.framesPerSecond || 0,
4981
- }))));
4982
- this.fpsAvgTick = toSignal(this.obs$.pipe(map$1((stat) => ({
4983
- value: stat.aggregatedStats.avgFrameRate,
4984
- }))));
4985
- this.bitrateTick = toSignal(this.obs$.pipe(map$1((stat) => ({
4986
- value: stat.aggregatedStats.bitrate,
4987
- }))));
4988
- this.videoQP = toSignal(this.obs$.pipe(map$1((stat) => ({
4989
- value: stat.aggregatedStats.VideoEncoderQP,
4990
- }))));
4991
- this.bitrateAverageTick = toSignal(this.obs$.pipe(map$1((stat) => ({
4992
- value: stat.aggregatedStats.avgBitrate,
4993
- }))));
4994
- this.videoStatus = toSignal(this.obs$.pipe(map$1((event) => this.isDevMode &&
4995
- {
4996
- ...event.aggregatedStats,
4997
- quality: event.quality,
4998
- }), filter(Boolean), map$1((data) => {
4999
- return this.fields
5000
- .map((key) => {
5001
- return {
5002
- key,
5003
- value: data[key],
5004
- };
5005
- })
5006
- .filter((r) => !this.elementsToShow.includes(r.key));
5007
- })));
5008
- this.graphList = [];
5009
- this.idKey = 'com.source3d.unreal.stat';
5010
- this.elementsToShow = [];
5011
- this.trigger$ = new BehaviorSubject(undefined);
5012
- this.getFields();
4622
+ super();
5013
4623
  }
5014
- ngOnInit() {
5015
- this.fields = [
5016
- 'kalmanBitrate',
5017
- 'bitrateDrop',
5018
- 'runTime',
5019
- 'pixelRatio',
5020
- 'frameHeight',
5021
- 'frameWidth',
5022
- ':-------------',
5023
- 'jitter',
5024
- 'MBytesReceived',
5025
- 'framesDecoded',
5026
- 'framesDropped',
5027
- 'framesReceived',
5028
- 'keyFramesDecoded',
5029
- 'currentRoundTripTime',
5030
- 'packetsLost',
5031
- ':-------------',
5032
- 'quality',
5033
- 'videoCodec',
5034
- 'kind',
5035
- 'type',
5036
- 'codecs',
5037
- 'selectedPair',
5038
- //'pliCount',
5039
- //'sliCount',
5040
- //'transportId',
5041
- //'id',
5042
- ];
5043
- this.updateGraph();
4624
+ init() {
4625
+ return;
5044
4626
  }
5045
- updateGraph() {
5046
- this.graphList = this.fields.map((key) => {
5047
- const stat = this.obs$.pipe(map$1((val) => {
5048
- const value = val.aggregatedStats[key];
5049
- return { value };
5050
- }));
5051
- return {
5052
- key,
5053
- color: Math.floor(Math.random() * 16777216).toString(),
5054
- stat,
5055
- };
5056
- });
5057
- this.graphList$ = of(this.graphList).pipe(combineLatestWith(this.trigger$), map$1(([graphList]) => graphList.filter((r) => this.elementsToShow.includes(r.key))));
4627
+ connectToSignaling() {
4628
+ this.store.dispatch(setUnrealPlaywrightConfig());
4629
+ this.store.dispatch(dataChannelConnected({
4630
+ statusMessage: 'Playwright set data channel connected',
4631
+ }));
4632
+ return EMPTY;
5058
4633
  }
5059
- toggle() {
5060
- this.collapsed.update((collapsed) => !collapsed);
5061
- this.writeFields();
4634
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
4635
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService }); }
4636
+ }
4637
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SignallingPlaywrightService, decorators: [{
4638
+ type: Injectable
4639
+ }], ctorParameters: () => [] });
4640
+
4641
+ class UnrealCommunicatorPlaywrightService extends UnrealCommunicatorService {
4642
+ init() {
4643
+ return;
5062
4644
  }
5063
- toggleGraph($event, el, value) {
5064
- $event.stopPropagation();
5065
- if ((value && typeof value !== 'number') ||
5066
- (typeof value === 'number' && isNaN(value))) {
5067
- return;
5068
- }
5069
- if (this.elementsToShow.includes(el)) {
5070
- this.elementsToShow = this.elementsToShow.filter((item) => item !== el);
5071
- }
5072
- else {
5073
- this.elementsToShow.push(el);
5074
- }
5075
- this.writeFields();
5076
- this.trigger$.next();
4645
+ sendCommandToUnreal() {
4646
+ return;
5077
4647
  }
5078
- getFields() {
5079
- try {
5080
- const { elements, collapsed } = JSON.parse(localStorage.getItem(this.idKey)) || [];
5081
- this.elementsToShow = elements || [];
5082
- this.collapsed.set(collapsed);
5083
- }
5084
- catch {
5085
- this.elementsToShow = [];
5086
- }
4648
+ sendInputData() {
4649
+ return;
5087
4650
  }
5088
- writeFields() {
5089
- localStorage.setItem(this.idKey, JSON.stringify({
5090
- collapsed: this.collapsed(),
5091
- elements: this.elementsToShow,
5092
- }));
4651
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4652
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService }); }
4653
+ }
4654
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCommunicatorPlaywrightService, decorators: [{
4655
+ type: Injectable
4656
+ }] });
4657
+
4658
+ class AggregatorPlaywrightService extends AggregatorService {
4659
+ init() {
4660
+ return;
5093
4661
  }
5094
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: VideoStatsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5095
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: VideoStatsComponent, isStandalone: true, selector: "app-video-stats", ngImport: i0, template: "@if (videoStatus() && viewportReady()) {\n <div class=\"settings-container\">\n <button (click)=\"toggle()\" class=\"gear-button\">Stats 2</button>\n <div (click)=\"toggle()\" [class.min]=\"collapsed()\" id=\"aggregatedStats\">\n <div class=\"forNerds\">\n <div (click)=\"$event.stopPropagation()\" class=\"static\">\n <li>\n <app-stat-graph\n [stat]=\"fpsTick()!\"\n label=\"FPS (higher is better)\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"videoQP()!\"\n label=\"QP (lower is better)\"\n color=\"#D5ff07\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"bitrateTick()!\"\n label=\"Bitrate\"\n color=\"#D57F07\"\n />\n </li>\n </div>\n <div>\n @for (graph of graphList$ | async; track graph) {\n <li (click)=\"toggleGraph($event, graph.key, 0)\" class=\"graph\">\n <app-stat-graph\n [label]=\"graph.key\"\n [stat]=\"(graph.stat | async)!\"\n [color]=\"graph.color\"\n />\n </li>\n }\n </div>\n </div>\n\n @for (el of videoStatus(); track el.key + $index) {\n <div (click)=\"toggleGraph($event, el.key, el.value)\" class=\"stat\">\n <span>{{ el.key }}: </span>{{ el.value | json }}\n </div>\n }\n </div>\n </div>\n @if (!collapsed()) {\n <div [innerHTML]=\"ssInfo() | safe: 'html'\" class=\"ssInfo\"></div>\n }\n}\n", styles: [".settings-container{position:absolute;top:65px;left:10px;z-index:1000;bottom:10px}.gear-button{left:0;width:50px;position:absolute;background:none;cursor:pointer;font-size:10px;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#fff;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}#aggregatedStats{max-height:calc(100% - 25px);overflow-y:auto;margin-top:25px;left:20px;z-index:31;max-width:400px;padding:10px;background-color:#fff3;border:1px solid grey}#aggregatedStats>*{color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000}#aggregatedStats:empty{display:none}#aggregatedStats button{position:absolute;right:5px;top:5px}#aggregatedStats.min{display:none}#aggregatedStats .forNerds{width:100%}#aggregatedStats .forNerds li{list-style-type:none;height:60px}#aggregatedStats .forNerds li app-stat-graph{display:block;width:100%;height:100%}#aggregatedStats .stat span{font-weight:700}#aggregatedStats .stat,#aggregatedStats .graph{cursor:pointer}#aggregatedStats .static{pointer-events:all}.ssInfo{position:absolute;top:0;left:50%;transform:translate(-50%);color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;text-align:center;width:80%;pointer-events:none}\n"], dependencies: [{ kind: "component", type: StatGraphComponent, selector: "app-stat-graph", inputs: ["label", "stat", "color", "tickStep"] }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: JsonPipe, name: "json" }, { kind: "pipe", type: SafePipe, name: "safe" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4662
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4663
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService }); }
5096
4664
  }
5097
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: VideoStatsComponent, decorators: [{
5098
- type: Component,
5099
- args: [{ selector: 'app-video-stats', changeDetection: ChangeDetectionStrategy.OnPush, imports: [StatGraphComponent, AsyncPipe, JsonPipe, SafePipe], template: "@if (videoStatus() && viewportReady()) {\n <div class=\"settings-container\">\n <button (click)=\"toggle()\" class=\"gear-button\">Stats 2</button>\n <div (click)=\"toggle()\" [class.min]=\"collapsed()\" id=\"aggregatedStats\">\n <div class=\"forNerds\">\n <div (click)=\"$event.stopPropagation()\" class=\"static\">\n <li>\n <app-stat-graph\n [stat]=\"fpsTick()!\"\n label=\"FPS (higher is better)\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"videoQP()!\"\n label=\"QP (lower is better)\"\n color=\"#D5ff07\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"bitrateTick()!\"\n label=\"Bitrate\"\n color=\"#D57F07\"\n />\n </li>\n </div>\n <div>\n @for (graph of graphList$ | async; track graph) {\n <li (click)=\"toggleGraph($event, graph.key, 0)\" class=\"graph\">\n <app-stat-graph\n [label]=\"graph.key\"\n [stat]=\"(graph.stat | async)!\"\n [color]=\"graph.color\"\n />\n </li>\n }\n </div>\n </div>\n\n @for (el of videoStatus(); track el.key + $index) {\n <div (click)=\"toggleGraph($event, el.key, el.value)\" class=\"stat\">\n <span>{{ el.key }}: </span>{{ el.value | json }}\n </div>\n }\n </div>\n </div>\n @if (!collapsed()) {\n <div [innerHTML]=\"ssInfo() | safe: 'html'\" class=\"ssInfo\"></div>\n }\n}\n", styles: [".settings-container{position:absolute;top:65px;left:10px;z-index:1000;bottom:10px}.gear-button{left:0;width:50px;position:absolute;background:none;cursor:pointer;font-size:10px;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#fff;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}#aggregatedStats{max-height:calc(100% - 25px);overflow-y:auto;margin-top:25px;left:20px;z-index:31;max-width:400px;padding:10px;background-color:#fff3;border:1px solid grey}#aggregatedStats>*{color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000}#aggregatedStats:empty{display:none}#aggregatedStats button{position:absolute;right:5px;top:5px}#aggregatedStats.min{display:none}#aggregatedStats .forNerds{width:100%}#aggregatedStats .forNerds li{list-style-type:none;height:60px}#aggregatedStats .forNerds li app-stat-graph{display:block;width:100%;height:100%}#aggregatedStats .stat span{font-weight:700}#aggregatedStats .stat,#aggregatedStats .graph{cursor:pointer}#aggregatedStats .static{pointer-events:all}.ssInfo{position:absolute;top:0;left:50%;transform:translate(-50%);color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;text-align:center;width:80%;pointer-events:none}\n"] }]
5100
- }], ctorParameters: () => [] });
4665
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AggregatorPlaywrightService, decorators: [{
4666
+ type: Injectable
4667
+ }] });
5101
4668
 
5102
- class ResumeStreamButtonComponent {
5103
- constructor() {
5104
- this.actions$ = inject(Actions);
5105
- this.store = inject(Store);
5106
- this.videoService = inject(VideoService);
5107
- this.destroyRef = inject(DestroyRef);
5108
- this.showReconnectPopup = this.store.selectSignal(selectShowReconnectPopup);
5109
- this.isLoaderScreenVisible = this.store.selectSignal(selectIsLoaderScreenVisible);
5110
- this.streamConfig = this.store.selectSignal(selectStreamConfig);
5111
- this.isExistMatchUrls = this.store.selectSignal(selectIsExistMatchUrls);
5112
- this.isSecondStart = signal(!!(this.streamConfig()?.autoStart && location.href.match(/^https/gi)), ...(ngDevMode ? [{ debugName: "isSecondStart" }] : []));
5113
- this.isShowResumeButton = computed(() => this.isLoaderScreenVisible() &&
5114
- this.showReconnectPopup() &&
5115
- this.isExistMatchUrls(), ...(ngDevMode ? [{ debugName: "isShowResumeButton" }] : []));
5116
- }
5117
- ngOnInit() {
5118
- this.store
5119
- .select(unrealFeature.selectCirrusConnected)
5120
- .pipe(skip$1(1), filter(Falsy), takeUntilDestroyed(this.destroyRef))
5121
- .subscribe(() => this.onDisconnect());
4669
+ class FreezeFramePlaywrightService extends FreezeFrameService {
4670
+ init() {
4671
+ return;
5122
4672
  }
5123
- ngAfterViewInit() {
5124
- this.actions$
5125
- .pipe(ofType(videoStreamInitialized), takeUntilDestroyed(this.destroyRef), filter(() => !!this.videoService.video?.paused))
5126
- .subscribe(() => {
5127
- Logger.info('Simulate Press Play');
5128
- this.videoService.play();
5129
- this.store.dispatch(resetAfk());
5130
- });
4673
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4674
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService }); }
4675
+ }
4676
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFramePlaywrightService, decorators: [{
4677
+ type: Injectable
4678
+ }] });
4679
+
4680
+ class CommandTelemetryPlaywrightService extends CommandTelemetryService {
4681
+ init() {
4682
+ return;
5131
4683
  }
5132
- connect() {
5133
- this.store.dispatch(startStream({ config: { autoStart: true } }));
4684
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4685
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService }); }
4686
+ }
4687
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandTelemetryPlaywrightService, decorators: [{
4688
+ type: Injectable
4689
+ }] });
4690
+
4691
+ class StreamStatusTelemetryPlaywrightService extends StreamStatusTelemetryService {
4692
+ init() {
4693
+ return;
5134
4694
  }
5135
- onDisconnect() {
5136
- this.isSecondStart.set(true);
4695
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4696
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService }); }
4697
+ }
4698
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StreamStatusTelemetryPlaywrightService, decorators: [{
4699
+ type: Injectable
4700
+ }] });
4701
+
4702
+ class ConsoleExtensionsPlaywrightService extends ConsoleExtensionsService {
4703
+ init() {
4704
+ return;
5137
4705
  }
5138
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResumeStreamButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5139
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: ResumeStreamButtonComponent, isStandalone: true, selector: "app-resume-stream-button", ngImport: i0, template: "@if (isShowResumeButton()) {\n <div class=\"resume-box\">\n @if (isSecondStart()) {\n <div class=\"resume-box__text\">\n Your stream has been paused due to inactivity\n </div>\n }\n\n <src-button\n (onClick)=\"connect()\"\n [isFullWidth]=\"true\"\n [data-testid]=\"'connect-button'\"\n size=\"lg\"\n weight=\"primary\"\n class=\"connect-button\"\n >\n {{ isSecondStart() ? 'Resume' : 'Start' }}\n </src-button>\n </div>\n}\n", styles: [".resume-box{padding:16px;position:absolute;bottom:0;left:50%;gap:12px;z-index:6;display:flex;transform:translate(-50%,-50%);flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .connect-button{width:100%}.resume-box .resume-box__text{color:var(--src-color-grey-500, #6b7280);text-align:center;font-family:var(--src-font-family-body);font-size:14px;font-style:normal;font-weight:400;line-height:24px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4706
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
4707
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService }); }
5140
4708
  }
5141
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResumeStreamButtonComponent, decorators: [{
5142
- type: Component,
5143
- args: [{ selector: 'app-resume-stream-button', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "@if (isShowResumeButton()) {\n <div class=\"resume-box\">\n @if (isSecondStart()) {\n <div class=\"resume-box__text\">\n Your stream has been paused due to inactivity\n </div>\n }\n\n <src-button\n (onClick)=\"connect()\"\n [isFullWidth]=\"true\"\n [data-testid]=\"'connect-button'\"\n size=\"lg\"\n weight=\"primary\"\n class=\"connect-button\"\n >\n {{ isSecondStart() ? 'Resume' : 'Start' }}\n </src-button>\n </div>\n}\n", styles: [".resume-box{padding:16px;position:absolute;bottom:0;left:50%;gap:12px;z-index:6;display:flex;transform:translate(-50%,-50%);flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .connect-button{width:100%}.resume-box .resume-box__text{color:var(--src-color-grey-500, #6b7280);text-align:center;font-family:var(--src-font-family-body);font-size:14px;font-style:normal;font-weight:400;line-height:24px}\n"] }]
4709
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ConsoleExtensionsPlaywrightService, decorators: [{
4710
+ type: Injectable
5144
4711
  }] });
5145
4712
 
5146
- class IntroSrcComponent {
4713
+ class FileReceiverPlaywrightService extends FileReceiverService {
4714
+ init() {
4715
+ return;
4716
+ }
4717
+ }
4718
+
4719
+ /**
4720
+ * Adaptive FPS controller that monitors the incoming WebRTC video stream
4721
+ * quality and dynamically adjusts Unreal Engine's `t.MaxFPS` console variable.
4722
+ *
4723
+ * ## How it works
4724
+ *
4725
+ * The service subscribes to `VideoService.videoStats$` (emitted every 250 ms)
4726
+ * and collects samples of FPS, bitrate, and QP (Quantisation Parameter).
4727
+ * Every {@link SAMPLE_WINDOW} ticks (~2 s) it averages the collected samples
4728
+ * and feeds them into a set of **fuzzy-logic membership functions** that
4729
+ * classify each metric into overlapping quality bands.
4730
+ *
4731
+ * ### Input signals
4732
+ *
4733
+ * **1. FPS ratio** = `actualFPS / currentTargetFPS` (0..1+)
4734
+ *
4735
+ * | Band | Range | Meaning |
4736
+ * |--------|---------------------|-----------------------------------------------|
4737
+ * | low | ratio < 0.7 → 1.0, linear ramp 0.7..0.9 → 0 | Stream cannot keep up with the target |
4738
+ * | ok | triangle 0.8..1.0, peak at 0.95 | Stream roughly matches the target |
4739
+ * | high | ratio 0.9..1.0 → ramp, >1.0 → 1.0 | Stream meets or exceeds the target |
4740
+ *
4741
+ * **2. Bitrate load** = `currentBitrate / MAX_BITRATE` (0..1+)
4742
+ *
4743
+ * `MAX_BITRATE` = 20 Mbit/s — the reference ceiling for the encoder.
4744
+ *
4745
+ * | Band | Range | Meaning |
4746
+ * |--------|-------------------------------------------------|-----------------------------------------------|
4747
+ * | high | load 0.5..0.8 → ramp, >0.8 → 1.0 | Encoder is using most of the available bandwidth |
4748
+ * | not high | load < 0.5 → 0.0 | Bandwidth headroom is available |
4749
+ *
4750
+ * **3. Quality** — mapped from QP via `mapQpToQuality()` (see {@link Quality}):
4751
+ *
4752
+ * | Quality | QP range | Meaning |
4753
+ * |----------|-----------|-------------------------------------------------|
4754
+ * | `lime` | QP <= 26 | Good — encoder has enough headroom |
4755
+ * | `orange` | QP 27..35 | Fair — encoder is under moderate pressure |
4756
+ * | `red` | QP > 35 | Poor — heavy compression, visible artefacts |
4757
+ *
4758
+ * ### Decision rules (evaluated in priority order, first match wins)
4759
+ *
4760
+ * | # | Condition | Decision |
4761
+ * |---|------------------------------------------------------------|--------------|
4762
+ * | 1 | FPS ratio is **low** (membership > 0.5) | **decrease** |
4763
+ * | 2 | FPS ratio is **ok** AND quality is `red` | **decrease** |
4764
+ * | 3 | FPS ratio is **ok** AND bitrate **high** AND quality is `orange` | **hold** |
4765
+ * | 4 | FPS ratio is **high** AND quality is `lime` AND bitrate **not high** | **increase** |
4766
+ * | — | Everything else | **hold** |
4767
+ *
4768
+ * ## Cooldown-based throttling
4769
+ *
4770
+ * The service continuously evaluates fuzzy rules every sample window.
4771
+ * After each FPS step change it enforces a cooldown before the next
4772
+ * change can be applied. The cooldown duration depends on the
4773
+ * (previous decision → next decision) pair:
4774
+ *
4775
+ * | Previous | Next | Cooldown |
4776
+ * |-----------|-----------|-----------------------------------------------------|
4777
+ * | decrease | decrease | {@link COOLDOWN_DECREASE_AFTER_DECREASE_MS} (2 s) |
4778
+ * | decrease | increase | {@link COOLDOWN_INCREASE_AFTER_DECREASE_MS} (10 s) |
4779
+ * | increase | decrease | {@link COOLDOWN_AFTER_INCREASE_MS} (2 s) |
4780
+ * | increase | increase | {@link COOLDOWN_AFTER_INCREASE_MS} (2 s) |
4781
+ *
4782
+ * This means the system reacts quickly after an increase (2 s in any
4783
+ * direction) but is cautious about stepping back up after a decrease
4784
+ * (10 s), while still allowing consecutive decreases at 2 s intervals.
4785
+ *
4786
+ * ## FPS dispatch
4787
+ *
4788
+ * FPS changes are dispatched via the NgRx `setMaxFps` action, which triggers
4789
+ * the existing `CommandsEffects.setMaxFps$` effect that sends the
4790
+ * `t.MaxFPS <value>` console command to Unreal Engine.
4791
+ */
4792
+ class FpsMonitorService {
5147
4793
  constructor() {
5148
- this.videoElement = viewChild('videoPlayerCover', ...(ngDevMode ? [{ debugName: "videoElement" }] : []));
4794
+ this.videoService = inject(VideoService);
5149
4795
  this.store = inject(Store);
5150
- this.imageIntroSrc = this.store.selectSignal(unrealFeature.selectImageIntroSrc);
5151
- this.videoIntroSrc = this.store.selectSignal(unrealFeature.selectVideoIntroSrc);
5152
- this.viewportReady = this.store.selectSignal(unrealFeature.selectViewportReady);
5153
- effect(() => {
5154
- const viewportReady = this.viewportReady();
5155
- const videoIntroSrc = this.videoIntroSrc();
5156
- const imageIntroSrc = this.imageIntroSrc();
5157
- untracked(() => {
5158
- if (viewportReady && (videoIntroSrc || imageIntroSrc)) {
5159
- this.store.dispatch(resetIntroSrc());
5160
- }
5161
- });
5162
- });
5163
- effect(() => {
5164
- const videoIntroSrc = this.videoIntroSrc();
5165
- const videoElement = this.videoElement();
5166
- if (!videoIntroSrc || !videoElement) {
5167
- return;
5168
- }
5169
- untracked(() => this.createVideo(videoIntroSrc, videoElement.nativeElement));
4796
+ /** Discrete FPS targets the controller can switch between (ascending order). */
4797
+ this.FPS_STEPS = [30, 40, 50, 60];
4798
+ /** Reference ceiling for bitrate (bits/sec) used to normalise bitrate load to 0..1. */
4799
+ this.MAX_BITRATE = 20_000_000;
4800
+ /**
4801
+ * Cooldown (ms) for any decision (increase or decrease) following an increase.
4802
+ * After an increase the system should react faster.
4803
+ */
4804
+ this.COOLDOWN_AFTER_INCREASE_MS = 2_000;
4805
+ /**
4806
+ * Cooldown (ms) for a decrease following a previous decrease.
4807
+ */
4808
+ this.COOLDOWN_DECREASE_AFTER_DECREASE_MS = 2_000;
4809
+ /**
4810
+ * Cooldown (ms) for an increase following a previous decrease.
4811
+ * The stream needs more time to stabilise before stepping back up.
4812
+ */
4813
+ this.COOLDOWN_INCREASE_AFTER_DECREASE_MS = 10_000;
4814
+ /**
4815
+ * Number of videoStats$ ticks to accumulate before evaluating.
4816
+ * At 250 ms per tick this gives a ~2 s sliding evaluation window,
4817
+ * long enough to smooth out single-frame jitter.
4818
+ */
4819
+ this.SAMPLE_WINDOW = 8;
4820
+ /** Current FPS target; starts at max (60 FPS) and adapts downward/upward. */
4821
+ this.currentFpsTarget = this.FPS_STEPS[this.FPS_STEPS.length - 1];
4822
+ this.samples = [];
4823
+ this.lastDecisionTime = Date.now();
4824
+ this.lastUpgrade = 'hold';
4825
+ this.init();
4826
+ }
4827
+ init() {
4828
+ this.videoService.videoStats$
4829
+ .pipe(withLatestFrom(this.store.select(unrealFeature.selectViewportReady)), filter(([, viewportReady]) => viewportReady))
4830
+ .subscribe(([stats]) => this.processTick(stats));
4831
+ }
4832
+ /**
4833
+ * Called on every videoStats$ emission (~250 ms).
4834
+ * Accumulates samples and evaluates once per SAMPLE_WINDOW.
4835
+ */
4836
+ processTick(stats) {
4837
+ const { framesPerSecond, bitrate, VideoEncoderQP } = stats.aggregatedStats;
4838
+ this.samples.push({
4839
+ fps: framesPerSecond ?? 0,
4840
+ bitrate: bitrate ?? 0,
4841
+ qp: VideoEncoderQP ?? 0,
5170
4842
  });
4843
+ // Wait until we have enough samples for a reliable average.
4844
+ if (this.samples.length < this.SAMPLE_WINDOW) {
4845
+ return;
4846
+ }
4847
+ const avg = this.averageSamples();
4848
+ this.samples = [];
4849
+ this.handleMonitoring(avg, this.currentFpsTarget);
5171
4850
  }
5172
- createVideo(src, videoContainer) {
5173
- const matchAbsolutePath = !!src.match(/.*\.(mp4|WebM)(\?.*)?$/i);
5174
- videoContainer.innerHTML = ''; // This will remove all existing content within the layout
5175
- const videoElement = document.createElement('video');
5176
- videoElement.autoplay = true;
5177
- videoElement.muted = true;
5178
- videoElement.loop = true;
5179
- if (!matchAbsolutePath) {
5180
- videoElement.poster = `${src}/cover.webp`;
4851
+ handleMonitoring(avg, currentTarget) {
4852
+ const decision = this.evaluateDecision(avg, currentTarget);
4853
+ this.upgradeFps(decision, avg.qp);
4854
+ }
4855
+ /**
4856
+ * Combines fuzzy membership values into a single discrete decision.
4857
+ * Rules are evaluated in priority order — first match wins.
4858
+ *
4859
+ * Inputs:
4860
+ * - `fpsRatio` = avgFPS / currentTargetFPS (e.g. 45/60 = 0.75)
4861
+ * - `bitrateLoad` = avgBitrate / 20 Mbit/s (e.g. 12M/20M = 0.6)
4862
+ * - `quality` = mapQpToQuality(avgQP) → 'lime' | 'orange' | 'red'
4863
+ */
4864
+ evaluateDecision(avg, currentTarget) {
4865
+ const fpsRatio = currentTarget > 0 ? avg.fps / currentTarget : 1;
4866
+ const bitrateLoad = avg.bitrate / this.MAX_BITRATE;
4867
+ const fpsLow = this.membershipFpsLow(fpsRatio);
4868
+ const fpsOk = this.membershipFpsOk(fpsRatio);
4869
+ const fpsHigh = this.membershipFpsHigh(fpsRatio);
4870
+ const bitrateHigh = this.membershipBitrateHigh(bitrateLoad);
4871
+ // Quality is derived from QP: lime (QP <= 26), orange (QP 27..35), red (QP > 35).
4872
+ const quality = qpToQuality(avg.qp);
4873
+ // Rule 1: FPS clearly below target (ratio roughly < 0.8) → step down immediately.
4874
+ if (fpsLow > 0.5) {
4875
+ return 'decrease';
5181
4876
  }
5182
- videoElement.style.position = 'absolute';
5183
- videoElement.style.width = '100%';
5184
- videoElement.style.height = '100%';
5185
- videoElement.style.objectFit = 'cover';
5186
- const sourceElement = document.createElement('source');
5187
- if (matchAbsolutePath) {
5188
- sourceElement.src = src;
4877
+ // Rule 2: FPS looks acceptable numerically, but encoder quality is 'red'
4878
+ // (QP > 35 — heavy compression artefacts). Reducing target FPS frees
4879
+ // encoder headroom and improves visual quality.
4880
+ if (fpsOk > 0.4 && quality === 'red') {
4881
+ return 'decrease';
5189
4882
  }
5190
- else {
5191
- sourceElement.src = `${src}/video.mp4`;
4883
+ // Rule 3: FPS is ok, but bitrate is above ~50-80% of 20 Mbit/s ceiling
4884
+ // and quality is 'orange' (QP 27..35). Near capacity — don't push higher.
4885
+ if (fpsOk > 0.4 && bitrateHigh > 0.5 && quality === 'orange') {
4886
+ return 'hold';
5192
4887
  }
5193
- sourceElement.type = 'video/mp4';
5194
- videoElement.appendChild(sourceElement);
5195
- videoElement.innerHTML += 'Browser not supported';
5196
- videoContainer.appendChild(videoElement);
5197
- }
5198
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: IntroSrcComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5199
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: IntroSrcComponent, isStandalone: true, selector: "app-intro-src", viewQueries: [{ propertyName: "videoElement", first: true, predicate: ["videoPlayerCover"], descendants: true, isSignal: true }], ngImport: i0, template: "@let image = imageIntroSrc();\n@if (image) {\n <div class=\"backImage\">\n <img [ngSrc]=\"image\" fill alt=\"imageSrc\" />\n </div>\n} @else if (videoIntroSrc()) {\n <div #videoPlayerCover class=\"backVideo\"></div>\n}\n", styles: [".backVideo{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backVideo video{position:absolute;width:100%;height:100%;object-fit:cover}.backImage{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backImage img{position:absolute;width:100%;height:100%;object-fit:cover}\n"], dependencies: [{ kind: "directive", type: NgOptimizedImage, selector: "img[ngSrc]", inputs: ["ngSrc", "ngSrcset", "sizes", "width", "height", "decoding", "loading", "priority", "loaderParams", "disableOptimizedSrcset", "fill", "placeholder", "placeholderConfig", "src", "srcset"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5200
- }
5201
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: IntroSrcComponent, decorators: [{
5202
- type: Component,
5203
- args: [{ selector: 'app-intro-src', imports: [NgOptimizedImage], changeDetection: ChangeDetectionStrategy.OnPush, template: "@let image = imageIntroSrc();\n@if (image) {\n <div class=\"backImage\">\n <img [ngSrc]=\"image\" fill alt=\"imageSrc\" />\n </div>\n} @else if (videoIntroSrc()) {\n <div #videoPlayerCover class=\"backVideo\"></div>\n}\n", styles: [".backVideo{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backVideo video{position:absolute;width:100%;height:100%;object-fit:cover}.backImage{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backImage img{position:absolute;width:100%;height:100%;object-fit:cover}\n"] }]
5204
- }], ctorParameters: () => [], propDecorators: { videoElement: [{ type: i0.ViewChild, args: ['videoPlayerCover', { isSignal: true }] }] } });
5205
-
5206
- class UnrealSceneComponent {
5207
- get resizeValues() {
5208
- if (!this.isStudio()) {
5209
- return this.getResizeValuesUniversal();
4888
+ // Rule 4: FPS meets/exceeds target (ratio >= ~0.95), quality is 'lime'
4889
+ // (QP <= 26), and bitrate is below 50% of ceiling — safe to step up.
4890
+ if (fpsHigh > 0.5 &&
4891
+ quality === 'lime' &&
4892
+ bitrateHigh < 0.5 &&
4893
+ this.currentFpsTarget < this.FPS_STEPS[this.FPS_STEPS.length - 1]) {
4894
+ return 'increase';
5210
4895
  }
5211
- return this.getResizeValues();
5212
- }
5213
- get pixelRatio() {
5214
- return window.devicePixelRatio;
4896
+ return 'hold';
5215
4897
  }
5216
- onMouseOver() {
5217
- this.changeMouseOverScene.emit(true);
4898
+ // ---------------------------------------------------------------------------
4899
+ // Fuzzy membership functions
4900
+ //
4901
+ // Each function maps a normalised input (0..1+) to a membership degree (0..1).
4902
+ // Overlapping trapezoid/triangle shapes let multiple categories be partially
4903
+ // active at the same time, which makes the rules less brittle than hard thresholds.
4904
+ // ---------------------------------------------------------------------------
4905
+ /**
4906
+ * FPS ratio membership "low".
4907
+ * Input: ratio = actualFPS / targetFPS (e.g. 42/60 = 0.70).
4908
+ *
4909
+ * ratio < 0.7 → 1.0 (clearly failing, e.g. 40/60)
4910
+ * ratio 0.7..0.9 → linear ramp down (e.g. 0.8 → 0.5)
4911
+ * ratio > 0.9 → 0.0 (keeping up)
4912
+ */
4913
+ membershipFpsLow(ratio) {
4914
+ if (ratio < 0.7)
4915
+ return 1.0;
4916
+ if (ratio > 0.9)
4917
+ return 0.0;
4918
+ return (0.9 - ratio) / 0.2;
5218
4919
  }
5219
- onMouseOut() {
5220
- this.changeMouseOverScene.emit(false);
4920
+ /**
4921
+ * FPS ratio membership "ok" — triangle shape.
4922
+ * Input: ratio = actualFPS / targetFPS.
4923
+ *
4924
+ * ratio < 0.8 → 0.0
4925
+ * ratio 0.8..0.95 → ramp up, peak at 0.95 (e.g. 57/60)
4926
+ * ratio 0.95..1.0 → ramp down
4927
+ * ratio > 1.0 → 0.0
4928
+ */
4929
+ membershipFpsOk(ratio) {
4930
+ if (ratio < 0.8)
4931
+ return 0.0;
4932
+ if (ratio < 0.95)
4933
+ return (ratio - 0.8) / 0.15;
4934
+ if (ratio <= 1.0)
4935
+ return (1.0 - ratio) / 0.05;
4936
+ return 0.0;
5221
4937
  }
5222
- constructor() {
5223
- this.isStudio = input(false, ...(ngDevMode ? [{ debugName: "isStudio" }] : []));
5224
- this.useContainerAsSizeProvider = input(true, ...(ngDevMode ? [{ debugName: "useContainerAsSizeProvider" }] : []));
5225
- this.studioResolutionSize = input({
5226
- width: FULL_HD_WIDTH,
5227
- height: FULL_HD_HEIGHT,
5228
- }, ...(ngDevMode ? [{ debugName: "studioResolutionSize" }] : []));
5229
- this.studioResolutionValues = toObservable(computed(() => {
5230
- const values = this.studioResolutionSize();
5231
- return { width: values.width, height: values.height };
5232
- }));
5233
- this.videoElement = viewChild('videoContainer', ...(ngDevMode ? [{ debugName: "videoElement" }] : []));
5234
- this.changeMouseOverScene = output();
5235
- this.width = FULL_HD_WIDTH;
5236
- this.height = FULL_HD_HEIGHT;
5237
- this.actions$ = inject(Actions);
5238
- this.store = inject(Store);
5239
- this.isExistMatchUrls = this.store.selectSignal(selectIsExistMatchUrls);
5240
- this.isDevMode = inject(DevModeService).isDevMode;
5241
- this.lightMode = this.store.selectSignal(unrealFeature.selectLowBandwidth);
5242
- this.isFreezeFrameLoading = this.store.selectSignal(selectIsFreezeFrameLoading);
5243
- this.commandsSender = inject(UnrealCommunicatorService);
5244
- this.videoService = inject(VideoService);
5245
- this.element = inject(ElementRef);
5246
- this.destroyRef = inject(DestroyRef);
5247
- this.destroyRef.onDestroy(() => this.store.dispatch(destroyUnrealScene()));
5248
- effect(() => {
5249
- const videoElement = this.videoElement();
5250
- const isExistMatchUrls = this.isExistMatchUrls();
5251
- if (videoElement && isExistMatchUrls) {
5252
- this.store.dispatch(initSignalling());
5253
- }
5254
- });
5255
- afterNextRender({
5256
- read: () => {
5257
- const videoElement = this.videoElement();
5258
- if (videoElement) {
5259
- this.videoService.setContainer(videoElement.nativeElement);
5260
- if (this.element?.nativeElement) {
5261
- fromEvent(this.element?.nativeElement, 'contextmenu')
5262
- .pipe(takeUntilDestroyed(this.destroyRef))
5263
- .subscribe((event) => event.preventDefault());
5264
- }
5265
- this.listenResizeValues();
5266
- }
5267
- },
5268
- });
4938
+ /**
4939
+ * FPS ratio membership "high".
4940
+ * Input: ratio = actualFPS / targetFPS.
4941
+ *
4942
+ * ratio < 0.9 → 0.0
4943
+ * ratio 0.9..1.0 → linear ramp up (e.g. 0.95 → 0.5)
4944
+ * ratio >= 1.0 → 1.0 (meeting or exceeding target, e.g. 60/60)
4945
+ */
4946
+ membershipFpsHigh(ratio) {
4947
+ if (ratio < 0.9)
4948
+ return 0.0;
4949
+ if (ratio > 1.0)
4950
+ return 1.0;
4951
+ return (ratio - 0.9) / 0.1;
5269
4952
  }
5270
- listenResizeValues() {
5271
- const customSizeInputChanged$ = this.studioResolutionValues.pipe(filter(Truthy), distinctUntilChanged(), filter(({ width, height }) => this.width !== width || this.height !== height), map$1(({ width, height }) => ({ width, height })), tap(({ width, height }) => {
5272
- this.width = width;
5273
- this.height = height;
5274
- }));
5275
- const resizeRequired$ = merge(this.actions$.pipe(ofType(videoStreamInitialized)), fromEvent(window, 'resize'));
5276
- const videoStreamSizeChanged$ = this.videoService?.videoStats$.pipe(filter(() => !this.isFreezeFrameLoading()), map$1((stats) => ({
5277
- w: stats.aggregatedStats.frameWidth || 0,
5278
- h: stats.aggregatedStats.frameHeight || 0,
5279
- })), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5280
- fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD)));
5281
- merge(customSizeInputChanged$, resizeRequired$, videoStreamSizeChanged$)
5282
- .pipe(debounceTime(DEBOUNCE_TO_MANY_RESIZE_CALLS), switchMap(() => {
5283
- const key = Math.random();
5284
- // Monitoring the size of the video stream
5285
- return timer(0, 300).pipe(take(3), map$1(() => key));
5286
- }), map$1((key) => ({ key, ...this.resizeValues })), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5287
- fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD) &&
5288
- previous.key === current.key), tap((size) => this.adaptVideo(size)), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5289
- fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD)), combineLatestWith(this.store
5290
- .select(unrealFeature.selectDataChannelConnected)
5291
- .pipe(filter(Truthy))), takeUntilDestroyed(this.destroyRef))
5292
- .subscribe(([size]) => {
5293
- this.store.dispatch(changeStreamResolutionAction({ width: size.w, height: size.h }));
5294
- this.sendResize(size);
5295
- });
5296
- this.actions$
5297
- .pipe(ofType(forceResizeViewport), takeUntilDestroyed(this.destroyRef))
5298
- .subscribe(() => this.sendResize(this.resizeValues));
4953
+ /**
4954
+ * Bitrate load membership "high".
4955
+ * Input: load = currentBitrate / MAX_BITRATE (20 Mbit/s).
4956
+ *
4957
+ * load < 0.5 → 0.0 (below 10 Mbit/s — plenty of headroom)
4958
+ * load 0.5..0.8 → linear ramp (e.g. 13M/20M = 0.65 → 0.5)
4959
+ * load > 0.8 → 1.0 (above 16 Mbit/s — near capacity)
4960
+ */
4961
+ membershipBitrateHigh(load) {
4962
+ if (load < 0.5)
4963
+ return 0.0;
4964
+ if (load > 0.8)
4965
+ return 1.0;
4966
+ return (load - 0.5) / 0.3;
5299
4967
  }
5300
- adaptVideo(size) {
5301
- const video = this.videoService.video;
5302
- const areaSize = this.resizeValues;
5303
- if (video?.style) {
5304
- const pR = this.pixelRatio;
5305
- video.width = this.makeEven(size.w / pR / areaSize.scale);
5306
- video.height = this.makeEven(size.h / pR / areaSize.scale);
5307
- if (!this.isStudio()) {
5308
- video.style.overflow = 'hidden';
5309
- video.style.objectFit = 'cover';
5310
- video.style.width = `${video.width}px`;
5311
- video.style.height = `${video.height}px`;
5312
- }
4968
+ // ---------------------------------------------------------------------------
4969
+ // Step actions
4970
+ // ---------------------------------------------------------------------------
4971
+ upgradeFps(decision, qp) {
4972
+ if (decision === 'hold')
4973
+ return;
4974
+ const now = Date.now();
4975
+ // Throttle: cooldown depends on (previous decision → current decision) pair.
4976
+ const isStepUp = decision === 'increase';
4977
+ const cooldown = this.lastUpgrade === 'decrease'
4978
+ ? isStepUp
4979
+ ? this.COOLDOWN_INCREASE_AFTER_DECREASE_MS
4980
+ : this.COOLDOWN_DECREASE_AFTER_DECREASE_MS
4981
+ : this.COOLDOWN_AFTER_INCREASE_MS;
4982
+ if (now - this.lastDecisionTime < cooldown) {
4983
+ return;
5313
4984
  }
5314
- this.store.dispatch(videoAdaptedToContainer());
4985
+ this.lastUpgrade = decision;
4986
+ const quality = qpToQuality(qp);
4987
+ const newTarget = this.getNextFpsTarget(this.currentFpsTarget, decision);
4988
+ Logger.info(`[FpsMonitor] Decision: ${decision} → FPS: ${newTarget}, Quality: ${quality}`);
4989
+ if (newTarget !== this.currentFpsTarget) {
4990
+ this.currentFpsTarget = newTarget;
4991
+ this.store.dispatch(setMaxFps({ maxFps: newTarget }));
4992
+ }
4993
+ this.lastDecisionTime = now;
4994
+ this.samples = [];
4995
+ }
4996
+ /**
4997
+ * Get the next FPS target based on current target and decision.
4998
+ * Clamps to valid FPS_STEPS range.
4999
+ */
5000
+ getNextFpsTarget(currentTarget, decision) {
5001
+ const currentIndex = this.FPS_STEPS.indexOf(currentTarget);
5002
+ // If current target is not in FPS_STEPS, find closest one
5003
+ const validIndex = currentIndex === -1
5004
+ ? this.findClosestFpsIndex(currentTarget)
5005
+ : currentIndex;
5006
+ const delta = decision === 'increase' ? 1 : -1;
5007
+ const newIndex = clampf(0, this.FPS_STEPS.length - 1, validIndex + delta);
5008
+ return this.FPS_STEPS[newIndex];
5315
5009
  }
5316
5010
  /**
5317
- * This method calculates the dimensions for resizing a layout in a configurator stream.
5318
- * It takes into account the pixel ratio of the device's screen and the orientation of the layout.
5319
- * The method uses the `fitIntoRectangle` function to calculate the dimensions that would allow the layout to fit into the maximum space while maintaining its aspect ratio.
5320
- * It also sets the maximum limits for the width and height based on the layout's orientation.
5321
- * If the scale of the fitted dimensions is less than 1, it assigns the fitted dimensions to the output. Otherwise, it sets the scale of the output to 1.
5322
- * @returns {Area} An object of type `Area` that contains the calculated dimensions for resizing the layout.
5011
+ * Find the closest FPS step index for a given target value.
5323
5012
  */
5324
- getResizeValuesUniversal() {
5325
- const pR = this.pixelRatio;
5326
- let containerW = window.innerWidth || 2;
5327
- let containerH = window.innerHeight || 2;
5328
- if (this.useContainerAsSizeProvider()) {
5329
- containerW = this.videoElement()?.nativeElement.offsetWidth || 2;
5330
- containerH = this.videoElement()?.nativeElement.offsetHeight || 2;
5331
- }
5332
- const maxSpace = { w: containerW * pR, h: containerH * pR };
5333
- const isVertical = containerW < containerH;
5334
- let maxH = FULL_HD_HEIGHT;
5335
- let maxW = FULL_HD_WIDTH;
5336
- const params = new URLSearchParams(location.search);
5337
- const maxRes = params.get('maxresolution');
5338
- if (Truthy(maxRes)) {
5339
- maxH = FULL_HD_HEIGHT * 2;
5340
- maxW = FULL_HD_WIDTH * 2;
5341
- }
5342
- const maxLimits = isVertical
5343
- ? { w: maxH, h: maxW, x: 0, y: 0 }
5344
- : { w: maxW, h: maxH, x: 0, y: 0 };
5345
- const props = fitIntoRectangle({ x: 0, y: 0, w: containerW, h: containerH }, { x: 0, y: 0, ...maxSpace });
5346
- let out = { ...props };
5347
- const fitIntoMax = fitIntoRectangle(props, maxLimits);
5348
- if (fitIntoMax.scale < 1) {
5349
- // If reducing size to 1920x1080 is required, do it
5350
- out = fitIntoMax;
5013
+ findClosestFpsIndex(target) {
5014
+ let closestIndex = 0;
5015
+ let closestDiff = Math.abs(this.FPS_STEPS[0] - target);
5016
+ for (let i = 1; i < this.FPS_STEPS.length; i++) {
5017
+ const diff = Math.abs(this.FPS_STEPS[i] - target);
5018
+ if (diff < closestDiff) {
5019
+ closestDiff = diff;
5020
+ closestIndex = i;
5021
+ }
5351
5022
  }
5352
- else {
5353
- // Otherwise, use the lowest resolution because the layout size is smaller than 1920x1080
5354
- out.scale = 1;
5023
+ return closestIndex;
5024
+ }
5025
+ /** Average all collected samples for the current evaluation window. */
5026
+ averageSamples() {
5027
+ const len = this.samples.length;
5028
+ if (len === 0)
5029
+ return { fps: 0, bitrate: 0, qp: 0 };
5030
+ let fps = 0;
5031
+ let bitrate = 0;
5032
+ let qp = 0;
5033
+ for (const s of this.samples) {
5034
+ fps += s.fps;
5035
+ bitrate += s.bitrate;
5036
+ qp += s.qp;
5355
5037
  }
5356
- /**
5357
- * Ensure that the size is always even.
5358
- */
5359
- out.w = this.makeEven(out.w);
5360
- out.h = this.makeEven(out.h);
5361
- return { ...out };
5038
+ return { fps: fps / len, bitrate: bitrate / len, qp: qp / len };
5362
5039
  }
5363
- getResizeValues() {
5364
- const pR = this.pixelRatio;
5365
- const containerW = pR * (this.videoElement()?.nativeElement.offsetWidth || 2);
5366
- const containerH = pR * (this.videoElement()?.nativeElement.offsetHeight || 2);
5367
- const maxSpace = { w: containerW, h: containerH };
5368
- const videoSizePixelPerfect = fitIntoRectangle({ x: 0, y: 0, w: this.width, h: this.height }, { x: 0, y: 0, ...maxSpace });
5369
- const out = fitIntoRectangle(videoSizePixelPerfect, {
5370
- x: 0,
5371
- y: 0,
5372
- w: Math.min(containerW, FULL_HD_WIDTH),
5373
- h: Math.min(containerH, FULL_HD_HEIGHT),
5374
- });
5375
- /**
5376
- * Ensure that the size is always even.
5377
- */
5378
- out.w = this.makeEven(out.w);
5379
- out.h = this.makeEven(out.h);
5380
- return out;
5040
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5041
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService }); }
5042
+ }
5043
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorService, decorators: [{
5044
+ type: Injectable
5045
+ }], ctorParameters: () => [] });
5046
+
5047
+ class FpsMonitorPlaywrightService extends FpsMonitorService {
5048
+ init() {
5049
+ return;
5381
5050
  }
5382
- sendResize(size) {
5383
- this.commandsSender.sendCommandToUnreal({
5384
- command: MetaBoxCommand.FChangeResolutionCommand,
5385
- payload: { resolution: { x: size.w, y: size.h } },
5386
- });
5051
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
5052
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorPlaywrightService }); }
5053
+ }
5054
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FpsMonitorPlaywrightService, decorators: [{
5055
+ type: Injectable
5056
+ }] });
5057
+
5058
+ class InputPlaywrightService extends InputService {
5059
+ init() {
5060
+ return;
5387
5061
  }
5388
- makeEven(value) {
5389
- return Math.floor((value + 1) / 2) * 2;
5062
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
5063
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService }); }
5064
+ }
5065
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: InputPlaywrightService, decorators: [{
5066
+ type: Injectable
5067
+ }] });
5068
+
5069
+ const ReceivedMimeTypes = {
5070
+ ApplicationJson: 'application/json',
5071
+ };
5072
+
5073
+ class FileHandlerService {
5074
+ constructor() {
5075
+ this.fileService = inject(FileReceiverService);
5076
+ this.fileHandlers = {
5077
+ [ReceivedMimeTypes.ApplicationJson]: this.handleJsonFile.bind(this),
5078
+ };
5390
5079
  }
5391
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealSceneComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5392
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: UnrealSceneComponent, isStandalone: true, selector: "app-unreal-scene", inputs: { isStudio: { classPropertyName: "isStudio", publicName: "isStudio", isSignal: true, isRequired: false, transformFunction: null }, useContainerAsSizeProvider: { classPropertyName: "useContainerAsSizeProvider", publicName: "useContainerAsSizeProvider", isSignal: true, isRequired: false, transformFunction: null }, studioResolutionSize: { classPropertyName: "studioResolutionSize", publicName: "studioResolutionSize", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { changeMouseOverScene: "changeMouseOverScene" }, host: { listeners: { "mouseover": "onMouseOver()", "mouseout": "onMouseOut()" } }, viewQueries: [{ propertyName: "videoElement", first: true, predicate: ["videoContainer"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"frame unreal-container\">\n <div #videoContainer [class.lightModeCursor]=\"lightMode()\" id=\"player\"></div>\n\n <app-intro-src />\n <app-image-loading-src />\n <app-resume-stream-button />\n <app-unreal-status />\n <app-freeze-frame />\n <app-afk-timeout-modal />\n\n <ng-content select=\"app-pdf\"></ng-content>\n <ng-content select=\"app-show-case\"></ng-content>\n <ng-content select=\"app-low-bandwidth-detector\"></ng-content>\n\n @if (isDevMode) {\n <app-video-stats />\n }\n</div>\n", styles: [".unreal-container{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#fff}.unreal-container #player{position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:center;overflow:hidden}.unreal-container.select{cursor:url('data:image/svg+xml,<svg width=\"24\" height=\"24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M15.15 21.375a1.423 1.423 0 0 1-1.15.063 1.487 1.487 0 0 1-.85-.788l-3-6.45-2.325 3.25c-.283.4-.658.525-1.125.375-.467-.15-.7-.466-.7-.95V4.05c0-.417.188-.717.563-.9.375-.183.729-.142 1.062.125l10.1 7.95c.383.284.496.65.337 1.1-.158.45-.479.675-.962.675h-4.2l2.975 6.375c.183.384.204.767.063 1.15a1.487 1.487 0 0 1-.788.85Z\" fill=\"%23000\"/><path d=\"m12.697 20.861.002.005c.237.495.617.852 1.128 1.04.515.191 1.039.16 1.539-.08a1.987 1.987 0 0 0 1.04-1.128 1.922 1.922 0 0 0-.079-1.536v-.003L13.684 13.5H17.1c.32 0 .628-.075.89-.26.263-.184.438-.447.544-.749.106-.3.135-.617.04-.925a1.458 1.458 0 0 0-.545-.738L7.936 2.883a1.51 1.51 0 0 0-.768-.336 1.471 1.471 0 0 0-.825.154 1.495 1.495 0 0 0-.626.547 1.492 1.492 0 0 0-.217.802v12.825c0 .324.08.634.272.897.193.261.467.43.775.53.31.099.632.12.942.016.31-.103.555-.313.743-.578h.001l1.825-2.552 2.639 5.673Z\" stroke=\"%23fff\" stroke-opacity=\".7\"/></svg>') 6 2,auto}.unreal-container.orbit{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.998 21.6c-1.966 0-3.737-.53-5.312-1.588-1.575-1.058-2.738-2.437-3.488-4.137a.852.852 0 0 1 .013-.713.867.867 0 0 1 .512-.487.86.86 0 0 1 .688.037c.225.109.387.28.487.513.617 1.35 1.559 2.45 2.825 3.3 1.267.85 2.692 1.275 4.275 1.275 1.384 0 2.646-.33 3.788-.988a7.985 7.985 0 0 0 2.762-2.612h-.85a.873.873 0 0 1-.64-.257.863.863 0 0 1-.26-.638.88.88 0 0 1 .26-.643.864.864 0 0 1 .64-.262h3c.255 0 .47.086.642.259a.87.87 0 0 1 .258.64v3a.874.874 0 0 1-.257.642.863.863 0 0 1-.637.259.88.88 0 0 1-.643-.259.865.865 0 0 1-.263-.641v-.725a9.95 9.95 0 0 1-3.35 2.925c-1.35.733-2.833 1.1-4.45 1.1Zm0-17.4c-1.383 0-2.645.33-3.787.987A7.985 7.985 0 0 0 5.448 7.8h.85c.255 0 .47.086.642.257a.863.863 0 0 1 .258.638.88.88 0 0 1-.258.642.864.864 0 0 1-.642.263h-3a.87.87 0 0 1-.64-.259.87.87 0 0 1-.26-.641v-3c0-.255.086-.469.258-.641a.863.863 0 0 1 .637-.26.88.88 0 0 1 .643.26.864.864 0 0 1 .262.64v.726A9.95 9.95 0 0 1 7.548 3.5c1.35-.733 2.834-1.1 4.45-1.1 2 0 3.788.542 5.363 1.625 1.575 1.083 2.736 2.49 3.482 4.22a.724.724 0 0 1-.045.646.926.926 0 0 1-.525.437.87.87 0 0 1-.687-.04.977.977 0 0 1-.488-.513c-.616-1.35-1.558-2.45-2.825-3.3-1.266-.85-2.691-1.275-4.275-1.275Zm0 10.8a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 8.998 12c0-.833.292-1.542.875-2.125A2.893 2.893 0 0 1 11.998 9c.834 0 1.542.292 2.125.875.584.583.875 1.292.875 2.125s-.291 1.542-.875 2.125a2.893 2.893 0 0 1-2.125.875Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"m2.739 16.072.002.005c.789 1.788 2.014 3.24 3.666 4.35 1.66 1.116 3.53 1.673 5.591 1.673 1.696 0 3.262-.386 4.69-1.16a10.54 10.54 0 0 0 2.714-2.097c.069.167.171.32.308.454.272.268.613.403.994.403.38 0 .722-.135.992-.406s.402-.614.402-.994v-3c0-.381-.134-.724-.405-.995a1.37 1.37 0 0 0-.995-.405h-3c-.383 0-.727.137-.997.411a1.38 1.38 0 0 0-.403.994c0 .38.135.723.407.992.238.237.532.369.858.397a7.552 7.552 0 0 1-2.027 1.685c-1.062.613-2.237.92-3.538.92-1.488 0-2.815-.397-3.996-1.19-1.19-.798-2.07-1.826-2.647-3.087-.147-.34-.393-.6-.727-.76a1.36 1.36 0 0 0-1.073-.058c-.37.133-.64.397-.8.754a1.351 1.351 0 0 0-.016 1.114Zm4.553-8.37a1.36 1.36 0 0 0-.858-.396A7.552 7.552 0 0 1 8.46 5.62c1.062-.613 2.237-.921 3.537-.921 1.489 0 2.816.398 3.997 1.19 1.19.8 2.07 1.827 2.646 3.088.148.34.394.6.728.76a1.37 1.37 0 0 0 1.068.063c.347-.12.62-.346.799-.668.187-.338.215-.71.072-1.072l-.003-.007-.003-.007c-.784-1.818-2.006-3.298-3.658-4.434C15.982 2.47 14.094 1.9 11.998 1.9c-1.695 0-3.261.385-4.688 1.16a10.536 10.536 0 0 0-2.715 2.096 1.364 1.364 0 0 0-.308-.453 1.38 1.38 0 0 0-.994-.403c-.38 0-.722.135-.992.406s-.403.614-.403.994v3c0 .38.135.724.406.995.27.27.614.405.994.405h3c.384 0 .727-.137.998-.412a1.38 1.38 0 0 0 .402-.993c0-.38-.135-.723-.406-.992ZM9.52 14.48a3.393 3.393 0 0 0 2.478 1.02c.965 0 1.8-.342 2.479-1.02a3.392 3.392 0 0 0 1.021-2.48c0-.963-.343-1.8-1.021-2.478A3.392 3.392 0 0 0 11.998 8.5c-.964 0-1.8.343-2.478 1.021A3.392 3.392 0 0 0 8.498 12c0 .964.344 1.8 1.022 2.479Z\"/></svg>') 12 12,auto}.unreal-container.pan{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.997 10.075a1.01 1.01 0 0 1-.738-.313 1.008 1.008 0 0 1-.312-.737V6.15l-.95.95a.998.998 0 0 1-.763.312 1.09 1.09 0 0 1-.763-.337 1.06 1.06 0 0 1-.324-.775c0-.3.108-.558.324-.775l2.776-2.775c.117-.116.237-.2.362-.25a1.04 1.04 0 0 1 .775 0c.125.05.246.134.363.25l2.8 2.8c.216.217.32.47.313.762a1.096 1.096 0 0 1-.338.763 1.058 1.058 0 0 1-.775.325c-.3 0-.558-.108-.775-.325l-.925-.925v2.875c0 .283-.104.53-.312.737a1.01 1.01 0 0 1-.738.313Zm0 11.5a1.04 1.04 0 0 1-.388-.075 1.103 1.103 0 0 1-.362-.25l-2.8-2.8a1.002 1.002 0 0 1-.313-.762c.009-.292.121-.546.337-.763.217-.217.475-.325.775-.325.3 0 .559.108.775.325l.926.925v-2.875c0-.283.104-.529.312-.737a1.01 1.01 0 0 1 .738-.313c.283 0 .529.104.738.313.208.208.312.454.312.737v2.875l.95-.95a.998.998 0 0 1 .762-.312c.292.008.546.12.763.337.216.217.324.475.324.775 0 .3-.108.559-.324.775l-2.775 2.775c-.117.116-.238.2-.363.25a1.04 1.04 0 0 1-.387.075Zm4.925-6.05a1.058 1.058 0 0 1-.326-.774c0-.3.109-.559.326-.776l.925-.925H14.97c-.283 0-.528-.104-.736-.312a1.01 1.01 0 0 1-.313-.738c0-.283.104-.53.313-.738.208-.208.453-.312.737-.312h2.875l-.95-.95a.998.998 0 0 1-.313-.762 1.09 1.09 0 0 1 .338-.764 1.06 1.06 0 0 1 .775-.324c.3 0 .558.108.775.324l2.774 2.776c.117.117.2.238.251.363a1.04 1.04 0 0 1 0 .775c-.05.124-.134.245-.25.362l-2.8 2.8c-.217.216-.471.32-.762.313a1.096 1.096 0 0 1-.763-.337Zm-11.4 0L2.746 12.75a1.103 1.103 0 0 1-.25-.362 1.04 1.04 0 0 1 0-.775c.05-.125.133-.246.25-.363l2.8-2.8c.216-.216.47-.32.762-.313.292.01.546.121.763.337.217.217.325.476.325.776 0 .3-.108.558-.325.775l-.925.925h2.875c.283 0 .529.104.737.312.208.209.313.455.313.738s-.105.53-.313.738a1.008 1.008 0 0 1-.737.312H6.147l.95.95c.217.217.32.471.312.762a1.09 1.09 0 0 1-.337.764 1.06 1.06 0 0 1-.775.324c-.3 0-.559-.108-.776-.324Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M9.22 7.912c.436.013.821-.15 1.13-.458l.097-.097v1.668c0 .422.16.793.458 1.09.298.299.67.46 1.092.46.422 0 .793-.161 1.091-.46.298-.297.459-.668.459-1.09V7.357l.071.072c.309.308.693.471 1.13.471.435 0 .82-.163 1.128-.471.301-.303.47-.676.483-1.102v-.001a1.501 1.501 0 0 0-.46-1.13l-2.799-2.8a1.6 1.6 0 0 0-.528-.36l-.003-.001a1.54 1.54 0 0 0-1.145 0l-.002.001a1.6 1.6 0 0 0-.529.36L8.118 5.172l-.001.001a1.56 1.56 0 0 0-.47 1.128c0 .435.163.82.47 1.128.303.303.676.472 1.103.484Zm0 0 .014-.5-.015.5Zm-1.311 6.865a1.497 1.497 0 0 0-.458-1.13l-.097-.097h1.668c.421 0 .793-.16 1.09-.458.298-.299.46-.67.46-1.092a1.51 1.51 0 0 0-.46-1.091 1.507 1.507 0 0 0-1.09-.459H7.354l.071-.072c.309-.308.472-.692.472-1.128 0-.436-.163-.82-.472-1.13a1.596 1.596 0 0 0-1.101-.482h-.002a1.501 1.501 0 0 0-1.129.459l-2.8 2.8a1.6 1.6 0 0 0-.36.528l-.001.003a1.54 1.54 0 0 0 0 1.144v.003c.081.2.208.375.362.529l2.774 2.775c.309.308.693.47 1.129.47.435 0 .82-.162 1.128-.47.303-.302.472-.676.484-1.102Zm0 0-.5-.014.5.015v-.001Zm3.038-8.627-.5.5.5.207V6.15Zm.475 15.814.002.001a1.54 1.54 0 0 0 1.145 0h.002c.2-.081.375-.208.53-.362l2.775-2.774a1.56 1.56 0 0 0 .47-1.129c0-.435-.162-.82-.47-1.128a1.589 1.589 0 0 0-1.103-.484 1.497 1.497 0 0 0-1.13.458l-.096.097v-1.668c0-.421-.161-.793-.459-1.09a1.509 1.509 0 0 0-1.091-.46c-.422 0-.794.161-1.092.46a1.507 1.507 0 0 0-.458 1.09v1.668l-.072-.072a1.558 1.558 0 0 0-1.129-.471c-.436 0-.82.163-1.128.471l-.001.001c-.301.302-.47.675-.483 1.101v.001c-.012.437.151.821.46 1.13l2.8 2.8c.153.153.328.28.528.36Zm5.146-6.085c.303.302.676.47 1.102.484a1.5 1.5 0 0 0 1.13-.46l2.8-2.8a1.6 1.6 0 0 0 .36-.528l.002-.003a1.54 1.54 0 0 0 0-1.144l-.001-.003a1.6 1.6 0 0 0-.361-.529l-2.774-2.775h-.001a1.56 1.56 0 0 0-1.128-.47c-.436 0-.82.162-1.128.47h-.001a1.59 1.59 0 0 0-.484 1.102c-.012.437.15.822.459 1.13l.097.097h-1.668c-.422 0-.793.16-1.09.459-.299.298-.46.67-.46 1.091 0 .422.161.793.46 1.092.297.297.668.458 1.09.458h1.668l-.072.072a1.558 1.558 0 0 0-.472 1.129c0 .435.164.82.472 1.128ZM6.147 13.05l.5.5.207-.5h-.707Z\"/></svg>') 12 12,auto}.unreal-container.dolly{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><mask id=\"a\" width=\"24\" height=\"24\" x=\"0\" y=\"0\" maskUnits=\"userSpaceOnUse\" style=\"mask-type:alpha\"><path fill=\"%23127392\" d=\"M0 0h24v24H0z\"/></mask><g mask=\"url(%23a)\"><path fill=\"%23000\" d=\"M12.002 20.575a1.026 1.026 0 0 1-.75-.325l-2.55-2.55a.933.933 0 0 1-.287-.713c.008-.275.113-.513.313-.713.2-.2.441-.3.725-.3.283 0 .524.1.724.3l.776.776V6.95l-.8.8c-.2.2-.438.296-.712.288a1.018 1.018 0 0 1-.713-.312c-.2-.2-.3-.442-.3-.726s.1-.525.3-.725l2.524-2.525a1.026 1.026 0 0 1 1.5 0l2.55 2.55c.2.2.296.438.289.713a1.023 1.023 0 0 1-.314.713c-.2.2-.441.3-.724.3a.988.988 0 0 1-.725-.3l-.775-.776v10.1l.8-.8c.2-.2.437-.296.712-.288.275.008.512.112.712.312.2.2.3.442.3.726s-.1.525-.3.725l-2.524 2.525a1.026 1.026 0 0 1-.75.325Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M13.553 8.153c.28.245.62.373 1 .373.415 0 .784-.153 1.078-.447.287-.287.446-.644.46-1.05v-.002a1.432 1.432 0 0 0-.435-1.08l-2.55-2.55-.354.353.354-.354a1.526 1.526 0 0 0-2.207 0L8.374 5.921A1.487 1.487 0 0 0 7.928 7c0 .416.152.785.446 1.08.288.287.645.446 1.052.457l4.127-.384Zm0 0v7.693m0-7.693a1.667 1.667 0 0 1-.079-.074l-3.021.075v7.693a1.482 1.482 0 0 0-1-.373c-.416 0-.785.153-1.079.447a1.522 1.522 0 0 0-.46 1.05v.002c-.011.417.14.786.435 1.08l2.55 2.55a1.527 1.527 0 0 0 2.207 0l2.525-2.524c.294-.294.446-.663.446-1.079 0-.416-.152-.785-.446-1.08a1.518 1.518 0 0 0-1.052-.457m0 0-.014.5.014-.5Zm0 0a1.424 1.424 0 0 0-1.027.383m0 0a1.583 1.583 0 0 0-.053.05l.354.354m-.3-.404v.104l.3.3m0 0-.3-.3v.6l.3-.3Z\"/></g></svg>%0A') 12 12,auto}.unreal-container.disabled:before{content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}#playerUI{position:absolute;z-index:10;width:100%}#statsContainer{display:none;text-align:left;background-color:#000c}#stats{padding:6px;font-weight:700;font-size:14px;color:#0f0}canvas{position:absolute;image-rendering:crisp-edges}#overlay{position:absolute;top:0;right:2%;z-index:100;padding:4px;border-top-width:0;-webkit-border-bottom-right-radius:5px;-moz-border-radius-bottomright:5px;border-bottom-right-radius:5px;-webkit-border-bottom-left-radius:5px;-moz-border-radius-bottomleft:5px;border-bottom-left-radius:5px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none}.overlay{font-weight:lighter;font-family:var(--src-font-family-body)}#overlayButton:hover{cursor:pointer}#overlayButton{font-size:40px;text-align:right}#overlaySettings{display:none;width:300px;padding:4px}#videoMessageOverlay{position:absolute;z-index:20;width:100%;margin:auto;font-size:1.8em;font-family:var(--src-font-family-body)}#playButton{display:inline-block;height:auto}img#playButton{width:10%;max-width:241px}#UIInteraction{position:fixed}#UIInteractionButtonBoundary{padding:2px}#UIInteractionButton{cursor:pointer}#hiddenInput{position:absolute;left:-10%;width:0;opacity:0}#editTextButton{position:absolute;width:40px;height:40px}\n"], dependencies: [{ kind: "component", type: AfkTimeoutModalComponent, selector: "app-afk-timeout-modal" }, { kind: "component", type: UnrealStatusComponent, selector: "app-unreal-status" }, { kind: "component", type: FreezeFrameComponent, selector: "app-freeze-frame" }, { kind: "component", type: VideoStatsComponent, selector: "app-video-stats" }, { kind: "component", type: ResumeStreamButtonComponent, selector: "app-resume-stream-button" }, { kind: "component", type: ImageLoadingSrcComponent, selector: "app-image-loading-src" }, { kind: "component", type: IntroSrcComponent, selector: "app-intro-src" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5080
+ handleJsonFile(data, correlationId) {
5081
+ const { blob } = data;
5082
+ return from(blob.text()).pipe(map$1((text) => JSON.parse(text)), filter(({ commandCallback }) => {
5083
+ return commandCallback.correlationId === correlationId;
5084
+ }));
5085
+ }
5086
+ observeFileResponse(mimetype, data, sender, timeOut = 60000) {
5087
+ const correlationId = generateUuid();
5088
+ const out = { ...data, correlationId };
5089
+ const observable = this.fileService.fileComplete$.pipe(switchMap((data) => {
5090
+ if (data.valid && this.fileHandlers[mimetype]) {
5091
+ return this.fileHandlers[mimetype](data, correlationId).pipe(filter(Truthy));
5092
+ }
5093
+ return of(null);
5094
+ }), timeout(timeOut), catchError((e) => {
5095
+ Logger.error(e);
5096
+ return of(null);
5097
+ }), take(1));
5098
+ setTimeout(() => sender(out), 0);
5099
+ return observable;
5100
+ }
5101
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5102
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService }); }
5393
5103
  }
5394
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealSceneComponent, decorators: [{
5395
- type: Component,
5396
- args: [{ selector: 'app-unreal-scene', changeDetection: ChangeDetectionStrategy.OnPush, imports: [
5397
- AfkTimeoutModalComponent,
5398
- UnrealStatusComponent,
5399
- FreezeFrameComponent,
5400
- VideoStatsComponent,
5401
- ResumeStreamButtonComponent,
5402
- ImageLoadingSrcComponent,
5403
- IntroSrcComponent,
5404
- ], template: "<div class=\"frame unreal-container\">\n <div #videoContainer [class.lightModeCursor]=\"lightMode()\" id=\"player\"></div>\n\n <app-intro-src />\n <app-image-loading-src />\n <app-resume-stream-button />\n <app-unreal-status />\n <app-freeze-frame />\n <app-afk-timeout-modal />\n\n <ng-content select=\"app-pdf\"></ng-content>\n <ng-content select=\"app-show-case\"></ng-content>\n <ng-content select=\"app-low-bandwidth-detector\"></ng-content>\n\n @if (isDevMode) {\n <app-video-stats />\n }\n</div>\n", styles: [".unreal-container{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#fff}.unreal-container #player{position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:center;overflow:hidden}.unreal-container.select{cursor:url('data:image/svg+xml,<svg width=\"24\" height=\"24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M15.15 21.375a1.423 1.423 0 0 1-1.15.063 1.487 1.487 0 0 1-.85-.788l-3-6.45-2.325 3.25c-.283.4-.658.525-1.125.375-.467-.15-.7-.466-.7-.95V4.05c0-.417.188-.717.563-.9.375-.183.729-.142 1.062.125l10.1 7.95c.383.284.496.65.337 1.1-.158.45-.479.675-.962.675h-4.2l2.975 6.375c.183.384.204.767.063 1.15a1.487 1.487 0 0 1-.788.85Z\" fill=\"%23000\"/><path d=\"m12.697 20.861.002.005c.237.495.617.852 1.128 1.04.515.191 1.039.16 1.539-.08a1.987 1.987 0 0 0 1.04-1.128 1.922 1.922 0 0 0-.079-1.536v-.003L13.684 13.5H17.1c.32 0 .628-.075.89-.26.263-.184.438-.447.544-.749.106-.3.135-.617.04-.925a1.458 1.458 0 0 0-.545-.738L7.936 2.883a1.51 1.51 0 0 0-.768-.336 1.471 1.471 0 0 0-.825.154 1.495 1.495 0 0 0-.626.547 1.492 1.492 0 0 0-.217.802v12.825c0 .324.08.634.272.897.193.261.467.43.775.53.31.099.632.12.942.016.31-.103.555-.313.743-.578h.001l1.825-2.552 2.639 5.673Z\" stroke=\"%23fff\" stroke-opacity=\".7\"/></svg>') 6 2,auto}.unreal-container.orbit{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.998 21.6c-1.966 0-3.737-.53-5.312-1.588-1.575-1.058-2.738-2.437-3.488-4.137a.852.852 0 0 1 .013-.713.867.867 0 0 1 .512-.487.86.86 0 0 1 .688.037c.225.109.387.28.487.513.617 1.35 1.559 2.45 2.825 3.3 1.267.85 2.692 1.275 4.275 1.275 1.384 0 2.646-.33 3.788-.988a7.985 7.985 0 0 0 2.762-2.612h-.85a.873.873 0 0 1-.64-.257.863.863 0 0 1-.26-.638.88.88 0 0 1 .26-.643.864.864 0 0 1 .64-.262h3c.255 0 .47.086.642.259a.87.87 0 0 1 .258.64v3a.874.874 0 0 1-.257.642.863.863 0 0 1-.637.259.88.88 0 0 1-.643-.259.865.865 0 0 1-.263-.641v-.725a9.95 9.95 0 0 1-3.35 2.925c-1.35.733-2.833 1.1-4.45 1.1Zm0-17.4c-1.383 0-2.645.33-3.787.987A7.985 7.985 0 0 0 5.448 7.8h.85c.255 0 .47.086.642.257a.863.863 0 0 1 .258.638.88.88 0 0 1-.258.642.864.864 0 0 1-.642.263h-3a.87.87 0 0 1-.64-.259.87.87 0 0 1-.26-.641v-3c0-.255.086-.469.258-.641a.863.863 0 0 1 .637-.26.88.88 0 0 1 .643.26.864.864 0 0 1 .262.64v.726A9.95 9.95 0 0 1 7.548 3.5c1.35-.733 2.834-1.1 4.45-1.1 2 0 3.788.542 5.363 1.625 1.575 1.083 2.736 2.49 3.482 4.22a.724.724 0 0 1-.045.646.926.926 0 0 1-.525.437.87.87 0 0 1-.687-.04.977.977 0 0 1-.488-.513c-.616-1.35-1.558-2.45-2.825-3.3-1.266-.85-2.691-1.275-4.275-1.275Zm0 10.8a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 8.998 12c0-.833.292-1.542.875-2.125A2.893 2.893 0 0 1 11.998 9c.834 0 1.542.292 2.125.875.584.583.875 1.292.875 2.125s-.291 1.542-.875 2.125a2.893 2.893 0 0 1-2.125.875Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"m2.739 16.072.002.005c.789 1.788 2.014 3.24 3.666 4.35 1.66 1.116 3.53 1.673 5.591 1.673 1.696 0 3.262-.386 4.69-1.16a10.54 10.54 0 0 0 2.714-2.097c.069.167.171.32.308.454.272.268.613.403.994.403.38 0 .722-.135.992-.406s.402-.614.402-.994v-3c0-.381-.134-.724-.405-.995a1.37 1.37 0 0 0-.995-.405h-3c-.383 0-.727.137-.997.411a1.38 1.38 0 0 0-.403.994c0 .38.135.723.407.992.238.237.532.369.858.397a7.552 7.552 0 0 1-2.027 1.685c-1.062.613-2.237.92-3.538.92-1.488 0-2.815-.397-3.996-1.19-1.19-.798-2.07-1.826-2.647-3.087-.147-.34-.393-.6-.727-.76a1.36 1.36 0 0 0-1.073-.058c-.37.133-.64.397-.8.754a1.351 1.351 0 0 0-.016 1.114Zm4.553-8.37a1.36 1.36 0 0 0-.858-.396A7.552 7.552 0 0 1 8.46 5.62c1.062-.613 2.237-.921 3.537-.921 1.489 0 2.816.398 3.997 1.19 1.19.8 2.07 1.827 2.646 3.088.148.34.394.6.728.76a1.37 1.37 0 0 0 1.068.063c.347-.12.62-.346.799-.668.187-.338.215-.71.072-1.072l-.003-.007-.003-.007c-.784-1.818-2.006-3.298-3.658-4.434C15.982 2.47 14.094 1.9 11.998 1.9c-1.695 0-3.261.385-4.688 1.16a10.536 10.536 0 0 0-2.715 2.096 1.364 1.364 0 0 0-.308-.453 1.38 1.38 0 0 0-.994-.403c-.38 0-.722.135-.992.406s-.403.614-.403.994v3c0 .38.135.724.406.995.27.27.614.405.994.405h3c.384 0 .727-.137.998-.412a1.38 1.38 0 0 0 .402-.993c0-.38-.135-.723-.406-.992ZM9.52 14.48a3.393 3.393 0 0 0 2.478 1.02c.965 0 1.8-.342 2.479-1.02a3.392 3.392 0 0 0 1.021-2.48c0-.963-.343-1.8-1.021-2.478A3.392 3.392 0 0 0 11.998 8.5c-.964 0-1.8.343-2.478 1.021A3.392 3.392 0 0 0 8.498 12c0 .964.344 1.8 1.022 2.479Z\"/></svg>') 12 12,auto}.unreal-container.pan{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.997 10.075a1.01 1.01 0 0 1-.738-.313 1.008 1.008 0 0 1-.312-.737V6.15l-.95.95a.998.998 0 0 1-.763.312 1.09 1.09 0 0 1-.763-.337 1.06 1.06 0 0 1-.324-.775c0-.3.108-.558.324-.775l2.776-2.775c.117-.116.237-.2.362-.25a1.04 1.04 0 0 1 .775 0c.125.05.246.134.363.25l2.8 2.8c.216.217.32.47.313.762a1.096 1.096 0 0 1-.338.763 1.058 1.058 0 0 1-.775.325c-.3 0-.558-.108-.775-.325l-.925-.925v2.875c0 .283-.104.53-.312.737a1.01 1.01 0 0 1-.738.313Zm0 11.5a1.04 1.04 0 0 1-.388-.075 1.103 1.103 0 0 1-.362-.25l-2.8-2.8a1.002 1.002 0 0 1-.313-.762c.009-.292.121-.546.337-.763.217-.217.475-.325.775-.325.3 0 .559.108.775.325l.926.925v-2.875c0-.283.104-.529.312-.737a1.01 1.01 0 0 1 .738-.313c.283 0 .529.104.738.313.208.208.312.454.312.737v2.875l.95-.95a.998.998 0 0 1 .762-.312c.292.008.546.12.763.337.216.217.324.475.324.775 0 .3-.108.559-.324.775l-2.775 2.775c-.117.116-.238.2-.363.25a1.04 1.04 0 0 1-.387.075Zm4.925-6.05a1.058 1.058 0 0 1-.326-.774c0-.3.109-.559.326-.776l.925-.925H14.97c-.283 0-.528-.104-.736-.312a1.01 1.01 0 0 1-.313-.738c0-.283.104-.53.313-.738.208-.208.453-.312.737-.312h2.875l-.95-.95a.998.998 0 0 1-.313-.762 1.09 1.09 0 0 1 .338-.764 1.06 1.06 0 0 1 .775-.324c.3 0 .558.108.775.324l2.774 2.776c.117.117.2.238.251.363a1.04 1.04 0 0 1 0 .775c-.05.124-.134.245-.25.362l-2.8 2.8c-.217.216-.471.32-.762.313a1.096 1.096 0 0 1-.763-.337Zm-11.4 0L2.746 12.75a1.103 1.103 0 0 1-.25-.362 1.04 1.04 0 0 1 0-.775c.05-.125.133-.246.25-.363l2.8-2.8c.216-.216.47-.32.762-.313.292.01.546.121.763.337.217.217.325.476.325.776 0 .3-.108.558-.325.775l-.925.925h2.875c.283 0 .529.104.737.312.208.209.313.455.313.738s-.105.53-.313.738a1.008 1.008 0 0 1-.737.312H6.147l.95.95c.217.217.32.471.312.762a1.09 1.09 0 0 1-.337.764 1.06 1.06 0 0 1-.775.324c-.3 0-.559-.108-.776-.324Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M9.22 7.912c.436.013.821-.15 1.13-.458l.097-.097v1.668c0 .422.16.793.458 1.09.298.299.67.46 1.092.46.422 0 .793-.161 1.091-.46.298-.297.459-.668.459-1.09V7.357l.071.072c.309.308.693.471 1.13.471.435 0 .82-.163 1.128-.471.301-.303.47-.676.483-1.102v-.001a1.501 1.501 0 0 0-.46-1.13l-2.799-2.8a1.6 1.6 0 0 0-.528-.36l-.003-.001a1.54 1.54 0 0 0-1.145 0l-.002.001a1.6 1.6 0 0 0-.529.36L8.118 5.172l-.001.001a1.56 1.56 0 0 0-.47 1.128c0 .435.163.82.47 1.128.303.303.676.472 1.103.484Zm0 0 .014-.5-.015.5Zm-1.311 6.865a1.497 1.497 0 0 0-.458-1.13l-.097-.097h1.668c.421 0 .793-.16 1.09-.458.298-.299.46-.67.46-1.092a1.51 1.51 0 0 0-.46-1.091 1.507 1.507 0 0 0-1.09-.459H7.354l.071-.072c.309-.308.472-.692.472-1.128 0-.436-.163-.82-.472-1.13a1.596 1.596 0 0 0-1.101-.482h-.002a1.501 1.501 0 0 0-1.129.459l-2.8 2.8a1.6 1.6 0 0 0-.36.528l-.001.003a1.54 1.54 0 0 0 0 1.144v.003c.081.2.208.375.362.529l2.774 2.775c.309.308.693.47 1.129.47.435 0 .82-.162 1.128-.47.303-.302.472-.676.484-1.102Zm0 0-.5-.014.5.015v-.001Zm3.038-8.627-.5.5.5.207V6.15Zm.475 15.814.002.001a1.54 1.54 0 0 0 1.145 0h.002c.2-.081.375-.208.53-.362l2.775-2.774a1.56 1.56 0 0 0 .47-1.129c0-.435-.162-.82-.47-1.128a1.589 1.589 0 0 0-1.103-.484 1.497 1.497 0 0 0-1.13.458l-.096.097v-1.668c0-.421-.161-.793-.459-1.09a1.509 1.509 0 0 0-1.091-.46c-.422 0-.794.161-1.092.46a1.507 1.507 0 0 0-.458 1.09v1.668l-.072-.072a1.558 1.558 0 0 0-1.129-.471c-.436 0-.82.163-1.128.471l-.001.001c-.301.302-.47.675-.483 1.101v.001c-.012.437.151.821.46 1.13l2.8 2.8c.153.153.328.28.528.36Zm5.146-6.085c.303.302.676.47 1.102.484a1.5 1.5 0 0 0 1.13-.46l2.8-2.8a1.6 1.6 0 0 0 .36-.528l.002-.003a1.54 1.54 0 0 0 0-1.144l-.001-.003a1.6 1.6 0 0 0-.361-.529l-2.774-2.775h-.001a1.56 1.56 0 0 0-1.128-.47c-.436 0-.82.162-1.128.47h-.001a1.59 1.59 0 0 0-.484 1.102c-.012.437.15.822.459 1.13l.097.097h-1.668c-.422 0-.793.16-1.09.459-.299.298-.46.67-.46 1.091 0 .422.161.793.46 1.092.297.297.668.458 1.09.458h1.668l-.072.072a1.558 1.558 0 0 0-.472 1.129c0 .435.164.82.472 1.128ZM6.147 13.05l.5.5.207-.5h-.707Z\"/></svg>') 12 12,auto}.unreal-container.dolly{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><mask id=\"a\" width=\"24\" height=\"24\" x=\"0\" y=\"0\" maskUnits=\"userSpaceOnUse\" style=\"mask-type:alpha\"><path fill=\"%23127392\" d=\"M0 0h24v24H0z\"/></mask><g mask=\"url(%23a)\"><path fill=\"%23000\" d=\"M12.002 20.575a1.026 1.026 0 0 1-.75-.325l-2.55-2.55a.933.933 0 0 1-.287-.713c.008-.275.113-.513.313-.713.2-.2.441-.3.725-.3.283 0 .524.1.724.3l.776.776V6.95l-.8.8c-.2.2-.438.296-.712.288a1.018 1.018 0 0 1-.713-.312c-.2-.2-.3-.442-.3-.726s.1-.525.3-.725l2.524-2.525a1.026 1.026 0 0 1 1.5 0l2.55 2.55c.2.2.296.438.289.713a1.023 1.023 0 0 1-.314.713c-.2.2-.441.3-.724.3a.988.988 0 0 1-.725-.3l-.775-.776v10.1l.8-.8c.2-.2.437-.296.712-.288.275.008.512.112.712.312.2.2.3.442.3.726s-.1.525-.3.725l-2.524 2.525a1.026 1.026 0 0 1-.75.325Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M13.553 8.153c.28.245.62.373 1 .373.415 0 .784-.153 1.078-.447.287-.287.446-.644.46-1.05v-.002a1.432 1.432 0 0 0-.435-1.08l-2.55-2.55-.354.353.354-.354a1.526 1.526 0 0 0-2.207 0L8.374 5.921A1.487 1.487 0 0 0 7.928 7c0 .416.152.785.446 1.08.288.287.645.446 1.052.457l4.127-.384Zm0 0v7.693m0-7.693a1.667 1.667 0 0 1-.079-.074l-3.021.075v7.693a1.482 1.482 0 0 0-1-.373c-.416 0-.785.153-1.079.447a1.522 1.522 0 0 0-.46 1.05v.002c-.011.417.14.786.435 1.08l2.55 2.55a1.527 1.527 0 0 0 2.207 0l2.525-2.524c.294-.294.446-.663.446-1.079 0-.416-.152-.785-.446-1.08a1.518 1.518 0 0 0-1.052-.457m0 0-.014.5.014-.5Zm0 0a1.424 1.424 0 0 0-1.027.383m0 0a1.583 1.583 0 0 0-.053.05l.354.354m-.3-.404v.104l.3.3m0 0-.3-.3v.6l.3-.3Z\"/></g></svg>%0A') 12 12,auto}.unreal-container.disabled:before{content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}#playerUI{position:absolute;z-index:10;width:100%}#statsContainer{display:none;text-align:left;background-color:#000c}#stats{padding:6px;font-weight:700;font-size:14px;color:#0f0}canvas{position:absolute;image-rendering:crisp-edges}#overlay{position:absolute;top:0;right:2%;z-index:100;padding:4px;border-top-width:0;-webkit-border-bottom-right-radius:5px;-moz-border-radius-bottomright:5px;border-bottom-right-radius:5px;-webkit-border-bottom-left-radius:5px;-moz-border-radius-bottomleft:5px;border-bottom-left-radius:5px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none}.overlay{font-weight:lighter;font-family:var(--src-font-family-body)}#overlayButton:hover{cursor:pointer}#overlayButton{font-size:40px;text-align:right}#overlaySettings{display:none;width:300px;padding:4px}#videoMessageOverlay{position:absolute;z-index:20;width:100%;margin:auto;font-size:1.8em;font-family:var(--src-font-family-body)}#playButton{display:inline-block;height:auto}img#playButton{width:10%;max-width:241px}#UIInteraction{position:fixed}#UIInteractionButtonBoundary{padding:2px}#UIInteractionButton{cursor:pointer}#hiddenInput{position:absolute;left:-10%;width:0;opacity:0}#editTextButton{position:absolute;width:40px;height:40px}\n"] }]
5405
- }], ctorParameters: () => [], propDecorators: { isStudio: [{ type: i0.Input, args: [{ isSignal: true, alias: "isStudio", required: false }] }], useContainerAsSizeProvider: [{ type: i0.Input, args: [{ isSignal: true, alias: "useContainerAsSizeProvider", required: false }] }], studioResolutionSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "studioResolutionSize", required: false }] }], videoElement: [{ type: i0.ViewChild, args: ['videoContainer', { isSignal: true }] }], changeMouseOverScene: [{ type: i0.Output, args: ["changeMouseOverScene"] }], onMouseOver: [{
5406
- type: HostListener,
5407
- args: ['mouseover']
5408
- }], onMouseOut: [{
5409
- type: HostListener,
5410
- args: ['mouseout']
5411
- }] } });
5104
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FileHandlerService, decorators: [{
5105
+ type: Injectable
5106
+ }] });
5412
5107
 
5413
- class WebrtcErrorModalComponent {
5414
- #dialogRef = inject(DialogRef);
5108
+ class AnalyticsService {
5415
5109
  #store = inject(Store);
5416
- close() {
5417
- this.#dialogRef.close();
5110
+ #destroy$ = new Subject();
5111
+ sendEventToMixPanel(name, params = {}) {
5112
+ Logger.log('Analytics Event:', name, params);
5113
+ this.#store.dispatch(saveAnalyticsEvent({ event: { name, params } }));
5418
5114
  }
5419
- closeModalWithCirrusDisconnect() {
5115
+ constructor() {
5116
+ const abortSignal$ = this.#store
5117
+ .select(unrealFeature.selectViewportReady)
5118
+ .pipe(filter$1(Falsy), map(() => void 0));
5420
5119
  this.#store
5421
- .select(unrealFeature.selectCirrusConnected)
5422
- .pipe(take(1))
5423
- .subscribe((isConnected) => {
5424
- if (!isConnected) {
5425
- this.#store.dispatch(disconnectStream({
5426
- reason: DisconnectReason.DropConnection,
5427
- message: 'Modal Drop Connection',
5428
- }));
5120
+ .select(unrealFeature.selectViewportReady)
5121
+ .pipe(distinctUntilChanged(), filter$1(Truthy), tap(() => this.monitorMouse(abortSignal$)))
5122
+ .subscribe();
5123
+ }
5124
+ monitorMouse(abortSignal$) {
5125
+ this.#destroy$.next();
5126
+ const stop$ = merge(this.#destroy$.asObservable(), abortSignal$);
5127
+ const element = document.getElementById('streamingVideo');
5128
+ if (!element) {
5129
+ return;
5130
+ }
5131
+ // Monitor mousewheel events
5132
+ const mousewheel$ = fromEvent(element, 'wheel').pipe(takeUntil$1(stop$), throttleTime(1000));
5133
+ // Monitor mouse button states
5134
+ const mousedown$ = fromEvent(element, 'mousedown').pipe(takeUntil$1(stop$));
5135
+ const touchStart$ = fromEvent(element, 'touchstart').pipe(takeUntil$1(stop$));
5136
+ const touchMove$ = fromEvent(element, 'touchmove').pipe(takeUntil$1(stop$), throttleTime(200));
5137
+ // Track mouse movement while the left or right button is pressed
5138
+ mousedown$
5139
+ .pipe(filter$1((event) => event.button === 0 || event.button === 2), tap((downEvent) => {
5140
+ this.sendEventToMixPanel('mouse_interaction', {
5141
+ viewX: downEvent.clientX / element.clientWidth,
5142
+ viewY: downEvent.clientY / element.clientHeight,
5143
+ action: downEvent.button === 0 ? 'spinning' : 'panning',
5144
+ });
5145
+ }))
5146
+ .subscribe();
5147
+ // Track mouse movement while the left or right button is pressed
5148
+ touchStart$
5149
+ .pipe(tap((downEvent) => {
5150
+ const totalTouches = downEvent.touches.length;
5151
+ const touch = downEvent.touches[0];
5152
+ this.sendEventToMixPanel('touch_interaction', {
5153
+ viewX: touch.clientX / element.clientWidth,
5154
+ viewY: touch.clientY / element.clientHeight,
5155
+ action: totalTouches === 1 ? 'spinning' : 'panning',
5156
+ });
5157
+ }))
5158
+ .subscribe();
5159
+ let initialTouchDistance = 0;
5160
+ // Subscribe to mousewheel events
5161
+ touchMove$.subscribe((event) => {
5162
+ const totalTouches = event.touches.length;
5163
+ if (totalTouches < 2) {
5164
+ return;
5165
+ }
5166
+ // Calculate the current distance between two touches
5167
+ const touch1 = event.touches[0];
5168
+ const touch2 = event.touches[1];
5169
+ const currentDistance = Math.sqrt(Math.pow(touch2.clientX - touch1.clientX, 2) +
5170
+ Math.pow(touch2.clientY - touch1.clientY, 2));
5171
+ // Determine if zooming in or out based on distance change
5172
+ const deltaDistance = currentDistance - initialTouchDistance;
5173
+ const isZoomingIn = deltaDistance > 0;
5174
+ // Only send event if there's a significant change (avoid noise)
5175
+ if (Math.abs(deltaDistance) > 10) {
5176
+ this.sendEventToMixPanel('touch_zoom_interaction', {
5177
+ delta: isZoomingIn ? 1 : -1,
5178
+ action: isZoomingIn ? 'zoomin' : 'zoomout',
5179
+ });
5180
+ // Update initial distance for next comparison
5181
+ initialTouchDistance = currentDistance;
5429
5182
  }
5430
- this.close();
5183
+ });
5184
+ mousewheel$.subscribe((event) => {
5185
+ this.sendEventToMixPanel('mouse_zoom_interaction', {
5186
+ delta: -(event.deltaY / Math.abs(event.deltaY)),
5187
+ action: event.deltaY < 0 ? 'zoomin' : 'zoomout',
5188
+ });
5431
5189
  });
5432
5190
  }
5433
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebrtcErrorModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5434
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: WebrtcErrorModalComponent, isStandalone: true, selector: "app-webrtc-error-modal", ngImport: i0, template: "<div [attr.data-testid]=\"'webrtc-error-modal'\" class=\"src-modal\">\n <header class=\"src-modal__header\"></header>\n <section class=\"src-modal__body\">\n <div [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n WebRTC error\n </div>\n <div style=\"text-align: left\">\n An internet connection type (WebRTC) appears to be blocked either by your\n browser settings or your current network. If WebRTC is blocked on your\n browser you may be able to adjust these settings yourself based on\n instructions here:\n <a href=\"https://myownconference.com/blog/en/webrtc/\" target=\"blank\"\n >https://myownconference.com/blog/en/webrtc/</a\n >\n <br /><br />\n Trying a different web browser may help confirm this as well. If WebRTC is\n blocked by your network, try switching to a different network if possible\n or contact your network administrator.<br /><br />\n WebRTC is common, safe and increasingly utilised method for streaming real\n time 3D experiences via a web browser. It typically consumes no more\n bandwidth than streaming an HD video.\n </div>\n </section>\n <footer class=\"src-modal__footer\">\n <src-button\n (onClick)=\"closeModalWithCirrusDisconnect()\"\n [data-testid]=\"'close-webrtc-error-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </footer>\n</div>\n", styles: [".src-modal{--modalBodyPadding: 20px 8px 20px 20px;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto}.src-modal ::ng-deep .ng-scroll-content{--_scrollbar-content-width: initial;--_viewport-padding-right: 12px}.src-modal__body{width:100%}.src-modal__body p{color:var(--src-text-body-main, #1f2937);font-size:14px;font-style:normal;font-weight:400;line-height:24px;margin-top:0}.src-modal__body a{word-wrap:break-word;white-space:normal}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5191
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5192
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService }); }
5435
5193
  }
5436
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebrtcErrorModalComponent, decorators: [{
5437
- type: Component,
5438
- args: [{ selector: 'app-webrtc-error-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "<div [attr.data-testid]=\"'webrtc-error-modal'\" class=\"src-modal\">\n <header class=\"src-modal__header\"></header>\n <section class=\"src-modal__body\">\n <div [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n WebRTC error\n </div>\n <div style=\"text-align: left\">\n An internet connection type (WebRTC) appears to be blocked either by your\n browser settings or your current network. If WebRTC is blocked on your\n browser you may be able to adjust these settings yourself based on\n instructions here:\n <a href=\"https://myownconference.com/blog/en/webrtc/\" target=\"blank\"\n >https://myownconference.com/blog/en/webrtc/</a\n >\n <br /><br />\n Trying a different web browser may help confirm this as well. If WebRTC is\n blocked by your network, try switching to a different network if possible\n or contact your network administrator.<br /><br />\n WebRTC is common, safe and increasingly utilised method for streaming real\n time 3D experiences via a web browser. It typically consumes no more\n bandwidth than streaming an HD video.\n </div>\n </section>\n <footer class=\"src-modal__footer\">\n <src-button\n (onClick)=\"closeModalWithCirrusDisconnect()\"\n [data-testid]=\"'close-webrtc-error-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </footer>\n</div>\n", styles: [".src-modal{--modalBodyPadding: 20px 8px 20px 20px;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto}.src-modal ::ng-deep .ng-scroll-content{--_scrollbar-content-width: initial;--_viewport-padding-right: 12px}.src-modal__body{width:100%}.src-modal__body p{color:var(--src-text-body-main, #1f2937);font-size:14px;font-style:normal;font-weight:400;line-height:24px;margin-top:0}.src-modal__body a{word-wrap:break-word;white-space:normal}\n"] }]
5439
- }] });
5194
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AnalyticsService, decorators: [{
5195
+ type: Injectable
5196
+ }], ctorParameters: () => [] });
5440
5197
 
5441
- class FilterSettingsComponent {
5198
+ class UnrealCallbackService {
5442
5199
  constructor() {
5443
- this.store = inject(Store);
5444
- this.storageKey = 'LBMFilterSettings';
5445
- this.defaultFilterModel = { ...DefaultFilterModel };
5446
- this.settings = { ...DefaultFilterModel };
5447
- }
5448
- ngOnInit() {
5449
- const savedSettings = localStorage.getItem(this.storageKey);
5450
- if (savedSettings) {
5451
- this.dispatch(JSON.parse(savedSettings));
5452
- }
5200
+ this.actions$ = inject(Actions);
5453
5201
  }
5454
- togglePanel() {
5455
- this.settings.panelOpen = !this.settings.panelOpen;
5456
- this.saveSettings();
5202
+ /**
5203
+ * Listens for unreal command callbacks matching a specific command or event type.
5204
+ *
5205
+ * Supports both MetaBoxCommand keys (e.g. `MetaBoxCommand.FLoadProductCommand`)
5206
+ * and custom Unreal callback events (e.g. `'cameraChanged'`, `'onSceneState'`).
5207
+ *
5208
+ * @param command - The command or event key to filter callbacks by
5209
+ * @returns Observable that emits matching callback payloads
5210
+ *
5211
+ * @example
5212
+ * ```ts
5213
+ * // Listen for a MetaBox command callback
5214
+ * this.unrealCallbackService
5215
+ * .fromUnrealCallback(MetaBoxCommand.FLoadProductCommand)
5216
+ * .subscribe(data => console.log(data));
5217
+ *
5218
+ * // Listen for a custom Unreal event
5219
+ * this.unrealCallbackService
5220
+ * .fromUnrealCallback('cameraChanged')
5221
+ * .subscribe(data => console.log(data));
5222
+ * ```
5223
+ */
5224
+ fromUnrealCallback(command) {
5225
+ return this.actions$.pipe(ofType(unrealCommandCallback), map$1(({ json }) => {
5226
+ if ('commandCallback' in json) {
5227
+ return json.commandCallback;
5228
+ }
5229
+ return json;
5230
+ }), filter((json) => json.command === command), map$1((data) => ({
5231
+ command: data.command,
5232
+ payload: 'payload' in data ? data.payload : null,
5233
+ })));
5457
5234
  }
5458
- saveSettings() {
5459
- const valid = Object.entries(this.settings).reduce((acc, [key, value]) => {
5460
- return Truthy(value) ? { ...acc, [key]: value } : acc;
5461
- }, {});
5462
- this.dispatch({ ...DefaultFilterModel, ...valid });
5235
+ /**
5236
+ * Sends a command and observes the matching response by correlationId.
5237
+ * Replaces the old `observeCommandResponse` standalone function.
5238
+ *
5239
+ * @param data - The command data to send
5240
+ * @param sender - Function to send the command to Unreal
5241
+ * @param timeOut - Timeout in ms (default 60000)
5242
+ * @param dispatchOnTimeout - Whether to emit on timeout (default true)
5243
+ *
5244
+ * @example
5245
+ * ```ts
5246
+ * this.unrealCallbackService
5247
+ * .observeCommandResponse<FLoopBackCommand['command']>(
5248
+ * { command: MetaBoxCommand.FLoopBackCommand },
5249
+ * (data) => this.communicator.sendCommandToUnreal(data),
5250
+ * )
5251
+ * .subscribe();
5252
+ * ```
5253
+ */
5254
+ observeCommandResponse(data, sender, timeOut = 60000, dispatchOnTimeout = true) {
5255
+ const correlationId = generateUuid();
5256
+ 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
5257
+ ? of({ command: 'timeout', correlationId: 'timeout' })
5258
+ : of(null)), take(1), tapLog('Command Response Observer'), filter(Truthy), map$1((data) => ({
5259
+ command: data.command,
5260
+ payload: 'payload' in data ? data.payload : null,
5261
+ })));
5262
+ setTimeout(() => sender({ ...data, correlationId }), 0);
5263
+ return observable;
5463
5264
  }
5464
- dispatch(settings) {
5465
- try {
5466
- localStorage.setItem(this.storageKey, JSON.stringify(settings));
5467
- }
5468
- catch (e) {
5469
- Logger.error('Storage not available, Error saving filter settings:', e);
5470
- }
5471
- Object.assign(this.settings, settings);
5472
- Object.assign(FilterModel, settings);
5473
- this.store.dispatch(setKalmanSettings({ settings }));
5265
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5266
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService }); }
5267
+ }
5268
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealCallbackService, decorators: [{
5269
+ type: Injectable
5270
+ }] });
5271
+
5272
+ class AfkTimeoutModalComponent {
5273
+ constructor() {
5274
+ this.#store = inject(Store);
5275
+ this.#afkService = inject(AFKService);
5276
+ this.isAfkTimerVisible = this.#store.selectSignal(unrealFeature.selectIsAfkTimerVisible);
5277
+ this.afkCountdown = this.#store.selectSignal(unrealFeature.selectAfkCountdown);
5474
5278
  }
5475
- reset() {
5476
- this.dispatch({
5477
- ...DefaultFilterModel,
5478
- panelOpen: this.settings.panelOpen,
5479
- });
5279
+ #store;
5280
+ #afkService;
5281
+ resetAfk() {
5282
+ this.#afkService.reset();
5480
5283
  }
5481
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FilterSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5482
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: FilterSettingsComponent, isStandalone: true, selector: "app-filter-settings", ngImport: i0, template: "<div class=\"settings-container\">\n <button (click)=\"togglePanel()\" class=\"gear-button\">LBM</button>\n @if (settings.panelOpen) {\n <div class=\"settings-panel\">\n <h3>LBM Trigger Settings</h3>\n <h5>Data Flow Monitor</h5>\n <form>\n <label>\n Monitoring delay:\n <input\n [(ngModel)]=\"settings.monitoringDelayTime\"\n [placeholder]=\"defaultFilterModel.monitoringDelayTime\"\n type=\"number\"\n name=\"monitoringDelayTime\"\n />\n </label>\n\n <label>\n Minimum Bitrate (kbit/s):\n <input\n [(ngModel)]=\"settings.minimumBitrate\"\n [placeholder]=\"defaultFilterModel.minimumBitrate\"\n type=\"number\"\n name=\"minimumBitrate\"\n />\n </label>\n <label>\n Yellow Flag (%):\n <input\n [(ngModel)]=\"settings.yellowFlag\"\n [placeholder]=\"defaultFilterModel.yellowFlag\"\n type=\"number\"\n name=\"yellowFlag\"\n />\n </label>\n <label>\n Red Flag (%):\n <input\n [(ngModel)]=\"settings.redFlag\"\n [placeholder]=\"defaultFilterModel.redFlag\"\n type=\"number\"\n name=\"redFlag\"\n />\n </label>\n <label>\n Minimum FPS:\n <input\n [(ngModel)]=\"settings.minimumFps\"\n [placeholder]=\"defaultFilterModel.minimumFps\"\n type=\"number\"\n name=\"minimumFps\"\n />\n </label>\n <hr />\n <h5>Kalman Filter</h5>\n <label>\n Initial Bitrate Estimate (kbit/s):\n <input\n [(ngModel)]=\"settings.initialBitrateEstimate\"\n [placeholder]=\"defaultFilterModel.initialBitrateEstimate\"\n type=\"number\"\n name=\"initialBitrateEstimate\"\n />\n </label>\n <label>\n Initial Error Covariance:\n <input\n [(ngModel)]=\"settings.initialErrorCovariance\"\n [placeholder]=\"defaultFilterModel.initialErrorCovariance\"\n type=\"number\"\n name=\"initialErrorCovariance\"\n />\n </label>\n <label>\n Process Noise (Q):\n <input\n [(ngModel)]=\"settings.processNoise\"\n [placeholder]=\"defaultFilterModel.processNoise\"\n type=\"number\"\n name=\"processNoise\"\n />\n </label>\n <label>\n Measurement Noise (R):\n <input\n [(ngModel)]=\"settings.measurementNoise\"\n [placeholder]=\"defaultFilterModel.measurementNoise\"\n type=\"number\"\n name=\"measurementNoise\"\n />\n </label>\n </form>\n <br />\n <br />\n <div class=\"apply-button-container\">\n <button (click)=\"reset()\">Reset</button>\n <button (click)=\"saveSettings()\">Apply</button>\n </div>\n </div>\n }\n</div>\n", styles: [".settings-container{position:absolute;top:65px;right:10px;z-index:1000}.gear-button{right:0;width:50px;position:absolute;cursor:pointer;font-size:10px;background:none;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#e6f419;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}.settings-panel{font-size:13px;background:#fff;border:1px solid #ccc;padding:15px;margin-top:25px;border-radius:4px;box-shadow:0 2px 8px #0003}.settings-panel h3{margin-top:0}.settings-panel form{display:flex;flex-direction:column}.settings-panel label{margin-bottom:10px;display:flex;flex-direction:column}.settings-panel input{padding:4px;margin-top:4px;border:1px solid #ccc;border-radius:2px}.apply-button-container{position:absolute;bottom:15px;right:15px}.apply-button-container button{padding:6px 12px;border:none;background-color:#1976d2;color:#fff;border-radius:4px;cursor:pointer;margin:2px}.apply-button-container button:hover{background-color:#1565c0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5284
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkTimeoutModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5285
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: AfkTimeoutModalComponent, isStandalone: true, selector: "app-afk-timeout-modal", ngImport: i0, template: "@if (isAfkTimerVisible()) {\n <div (click)=\"resetAfk()\" class=\"clickableState\" id=\"videoPlayOverlay\">\n <div class=\"resume-box\">\n <div aria-hidden=\"true\" class=\"resume-box__pic\" role=\"presentation\">\n <div [innerHTML]=\"afkCountdown()\" class=\"text-number\"></div>\n </div>\n <div class=\"resume-box__text\">\n <h3 class=\"resume-box__heading\">Session will time out soon</h3>\n <p>\n No activity detected. Press 'Continue' if you wish to keep your\n session active\n </p>\n </div>\n <src-button\n [isFullWidth]=\"true\"\n [data-testid]=\"'continue-session'\"\n weight=\"primary\"\n size=\"lg\"\n >\n Continue\n </src-button>\n </div>\n </div>\n}\n", styles: ["#videoPlayOverlay{position:absolute;z-index:30;top:0;width:100%;height:100%;font-size:1.8em;font-family:var(--src-font-family-body);background-color:#646464b3}.clickableState{display:flex;justify-content:center;align-items:center;cursor:pointer}.textDisplayState{display:flex}.hiddenState{display:none}.resume-box{width:340px;padding:32px 20px 20px;flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .resume-box__pic{width:72px;height:72px;margin:0 auto 22px;border-radius:48px;background:#ecf0f2;padding:12px;display:flex;align-items:center;justify-content:center}.resume-box .resume-box__pic .text-number{color:var(--src-colors-text-default, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:30px;font-style:normal;font-weight:400;line-height:24px}.resume-box__text{margin-bottom:18px}.resume-box__text p{text-align:center;font-family:var(--src-font-family-body);font-size:var(--src-font-size-sm, 14px);font-style:normal;font-weight:400;line-height:24px;color:var(--src-text-body-secondary, #6b7280)}.resume-box__text .resume-box__heading{color:var(--src-text-body-main, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:18px;font-style:normal;font-weight:500;line-height:26px;margin-bottom:8px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5483
5286
  }
5484
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FilterSettingsComponent, decorators: [{
5287
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: AfkTimeoutModalComponent, decorators: [{
5485
5288
  type: Component,
5486
- args: [{ selector: 'app-filter-settings', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"settings-container\">\n <button (click)=\"togglePanel()\" class=\"gear-button\">LBM</button>\n @if (settings.panelOpen) {\n <div class=\"settings-panel\">\n <h3>LBM Trigger Settings</h3>\n <h5>Data Flow Monitor</h5>\n <form>\n <label>\n Monitoring delay:\n <input\n [(ngModel)]=\"settings.monitoringDelayTime\"\n [placeholder]=\"defaultFilterModel.monitoringDelayTime\"\n type=\"number\"\n name=\"monitoringDelayTime\"\n />\n </label>\n\n <label>\n Minimum Bitrate (kbit/s):\n <input\n [(ngModel)]=\"settings.minimumBitrate\"\n [placeholder]=\"defaultFilterModel.minimumBitrate\"\n type=\"number\"\n name=\"minimumBitrate\"\n />\n </label>\n <label>\n Yellow Flag (%):\n <input\n [(ngModel)]=\"settings.yellowFlag\"\n [placeholder]=\"defaultFilterModel.yellowFlag\"\n type=\"number\"\n name=\"yellowFlag\"\n />\n </label>\n <label>\n Red Flag (%):\n <input\n [(ngModel)]=\"settings.redFlag\"\n [placeholder]=\"defaultFilterModel.redFlag\"\n type=\"number\"\n name=\"redFlag\"\n />\n </label>\n <label>\n Minimum FPS:\n <input\n [(ngModel)]=\"settings.minimumFps\"\n [placeholder]=\"defaultFilterModel.minimumFps\"\n type=\"number\"\n name=\"minimumFps\"\n />\n </label>\n <hr />\n <h5>Kalman Filter</h5>\n <label>\n Initial Bitrate Estimate (kbit/s):\n <input\n [(ngModel)]=\"settings.initialBitrateEstimate\"\n [placeholder]=\"defaultFilterModel.initialBitrateEstimate\"\n type=\"number\"\n name=\"initialBitrateEstimate\"\n />\n </label>\n <label>\n Initial Error Covariance:\n <input\n [(ngModel)]=\"settings.initialErrorCovariance\"\n [placeholder]=\"defaultFilterModel.initialErrorCovariance\"\n type=\"number\"\n name=\"initialErrorCovariance\"\n />\n </label>\n <label>\n Process Noise (Q):\n <input\n [(ngModel)]=\"settings.processNoise\"\n [placeholder]=\"defaultFilterModel.processNoise\"\n type=\"number\"\n name=\"processNoise\"\n />\n </label>\n <label>\n Measurement Noise (R):\n <input\n [(ngModel)]=\"settings.measurementNoise\"\n [placeholder]=\"defaultFilterModel.measurementNoise\"\n type=\"number\"\n name=\"measurementNoise\"\n />\n </label>\n </form>\n <br />\n <br />\n <div class=\"apply-button-container\">\n <button (click)=\"reset()\">Reset</button>\n <button (click)=\"saveSettings()\">Apply</button>\n </div>\n </div>\n }\n</div>\n", styles: [".settings-container{position:absolute;top:65px;right:10px;z-index:1000}.gear-button{right:0;width:50px;position:absolute;cursor:pointer;font-size:10px;background:none;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#e6f419;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}.settings-panel{font-size:13px;background:#fff;border:1px solid #ccc;padding:15px;margin-top:25px;border-radius:4px;box-shadow:0 2px 8px #0003}.settings-panel h3{margin-top:0}.settings-panel form{display:flex;flex-direction:column}.settings-panel label{margin-bottom:10px;display:flex;flex-direction:column}.settings-panel input{padding:4px;margin-top:4px;border:1px solid #ccc;border-radius:2px}.apply-button-container{position:absolute;bottom:15px;right:15px}.apply-button-container button{padding:6px 12px;border:none;background-color:#1976d2;color:#fff;border-radius:4px;cursor:pointer;margin:2px}.apply-button-container button:hover{background-color:#1565c0}\n"] }]
5289
+ args: [{ selector: 'app-afk-timeout-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "@if (isAfkTimerVisible()) {\n <div (click)=\"resetAfk()\" class=\"clickableState\" id=\"videoPlayOverlay\">\n <div class=\"resume-box\">\n <div aria-hidden=\"true\" class=\"resume-box__pic\" role=\"presentation\">\n <div [innerHTML]=\"afkCountdown()\" class=\"text-number\"></div>\n </div>\n <div class=\"resume-box__text\">\n <h3 class=\"resume-box__heading\">Session will time out soon</h3>\n <p>\n No activity detected. Press 'Continue' if you wish to keep your\n session active\n </p>\n </div>\n <src-button\n [isFullWidth]=\"true\"\n [data-testid]=\"'continue-session'\"\n weight=\"primary\"\n size=\"lg\"\n >\n Continue\n </src-button>\n </div>\n </div>\n}\n", styles: ["#videoPlayOverlay{position:absolute;z-index:30;top:0;width:100%;height:100%;font-size:1.8em;font-family:var(--src-font-family-body);background-color:#646464b3}.clickableState{display:flex;justify-content:center;align-items:center;cursor:pointer}.textDisplayState{display:flex}.hiddenState{display:none}.resume-box{width:340px;padding:32px 20px 20px;flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .resume-box__pic{width:72px;height:72px;margin:0 auto 22px;border-radius:48px;background:#ecf0f2;padding:12px;display:flex;align-items:center;justify-content:center}.resume-box .resume-box__pic .text-number{color:var(--src-colors-text-default, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:30px;font-style:normal;font-weight:400;line-height:24px}.resume-box__text{margin-bottom:18px}.resume-box__text p{text-align:center;font-family:var(--src-font-family-body);font-size:var(--src-font-size-sm, 14px);font-style:normal;font-weight:400;line-height:24px;color:var(--src-text-body-secondary, #6b7280)}.resume-box__text .resume-box__heading{color:var(--src-text-body-main, #1f2937);text-align:center;font-family:var(--src-font-family-body);font-size:18px;font-style:normal;font-weight:500;line-height:26px;margin-bottom:8px}\n"] }]
5487
5290
  }] });
5488
5291
 
5489
- class LowBandwidthDetectorComponent {
5292
+ class FreezeFrameComponent {
5490
5293
  constructor() {
5491
- this.store = inject(Store);
5492
- this.dialog = inject(Dialog);
5493
- this.destroyRef = inject(DestroyRef);
5494
- this.videoService = inject(VideoService);
5495
- this.isDevMode = inject(DevModeService).isDevMode;
5496
- this.viewportReady$ = this.store.select(unrealFeature.selectViewportReady);
5497
- this.isLowBandwidth$ = this.store.select(unrealFeature.selectLowBandwidth);
5498
- this.lowBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Truthy));
5499
- this.highBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Falsy));
5500
- this.viewPortDestroyedTrigger$ = this.viewportReady$.pipe(distinctUntilChanged$1(), filter(Falsy));
5501
- this.aggregatedStats$ = this.videoService.videoStats$.pipe(map((data) => data.aggregatedStats), share());
5502
- this.nativeQuality$ = this.videoService.videoStats$.pipe(map((data) => data.quality));
5503
- this.monitoringTrigger$ = this.viewportReady$
5504
- .pipe(combineLatestWith(this.isLowBandwidth$), map(([viewPortReady, isLowBandwidth]) => viewPortReady && !isLowBandwidth))
5505
- .pipe(share());
5506
- this.reTrigger$ = this.monitoringTrigger$.pipe(map(() => ({ quality: 'lime' })));
5507
- this.canSwitchToLowBand$ = this.monitoringTrigger$.pipe(debounceTime(100), switchMap((canSwitch) => concat(of(false), timer(FilterModel.monitoringDelayTime).pipe(map(() => canSwitch))).pipe(take(2), takeUntil$1(this.monitoringTrigger$))), distinctUntilChanged$1(), share());
5508
- this.videoQuality$ = merge(this.nativeQuality$, this.reTrigger$).pipe(share(), takeUntil$1(this.viewPortDestroyedTrigger$));
5509
- this.fps$ = this.aggregatedStats$.pipe(map((data) => data.framesPerSecond || 0));
5510
- this.bitrateDrop$ = this.aggregatedStats$.pipe(map((data) => data.dataFlowCheckResult), filter((br) => br.isDropDetected), takeUntil$1(merge(this.viewPortDestroyedTrigger$, this.reTrigger$)));
5511
- this.fps = toSignal(this.fps$, { initialValue: 0 });
5512
- this.nativeQuality = toSignal(this.nativeQuality$);
5513
- this.isLowBandwidth = toSignal(this.isLowBandwidth$);
5514
- this.aggregatedStats = toSignal(this.aggregatedStats$);
5515
- this.isReducedQuality = signal(false, ...(ngDevMode ? [{ debugName: "isReducedQuality" }] : []));
5516
- this.isIndicatorExpanded = signal(false, ...(ngDevMode ? [{ debugName: "isIndicatorExpanded" }] : []));
5517
- this.isIndicatorExpandedTrigger$ = toObservable(this.isIndicatorExpanded).pipe(skip(1), distinctUntilChanged$1(), filter(Falsy));
5294
+ this.#store = inject(Store);
5295
+ this.freezeFrameProgressMessageFromVideo = this.#store.selectSignal(selectFreezeFrameProgressMessageFromVideo);
5296
+ this.combinedFreeze = this.#store.selectSignal(selectFreezeFrameCombinedDataUrl);
5518
5297
  }
5519
- ngOnInit() {
5520
- this.lowBandwidthTrigger$
5521
- .pipe(takeUntilDestroyed(this.destroyRef))
5522
- .subscribe(() => this.toggleIndicator(true));
5523
- merge(this.viewPortDestroyedTrigger$, this.highBandwidthTrigger$)
5524
- .pipe(takeUntilDestroyed(this.destroyRef))
5525
- .subscribe(() => this.isReducedQuality.set(false));
5526
- this.canSwitchToLowBand$
5527
- .pipe(filter(Truthy), tap(() => {
5528
- BITRATE_MONITOR.reset();
5529
- this.isReducedQuality.set(false);
5530
- }), switchMap(() => this.bitrateDrop$), tap(this.trySetLowBandwidth.bind(this)), map((dataFlowResult) => dataFlowResult?.isDropDetected), distinctUntilChanged$1(), tap(() => {
5531
- this.isReducedQuality.set(true);
5532
- }), switchMap(() => this.videoQuality$), filter((quality) => quality === 'lime'), tap(() => this.isReducedQuality.set(false)), takeUntilDestroyed(this.destroyRef))
5533
- .subscribe();
5298
+ #store;
5299
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFrameComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5300
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: FreezeFrameComponent, isStandalone: true, selector: "app-freeze-frame", ngImport: i0, template: "@let image = combinedFreeze();\n@let progress = freezeFrameProgressMessageFromVideo();\n\n<div class=\"freeze-images\">\n @if (image) {\n <img [src]=\"image\" class=\"videoStillImage\" alt=\"freezeFrameImage\" />\n }\n\n @if (progress) {\n <div class=\"progress-status\">\n {{ progress }}\n </div>\n }\n</div>\n", styles: [".freeze-images{pointer-events:none;position:absolute;left:0;top:0;width:100%;height:100%;z-index:1}.freeze-images .freezeFrameOverlay{top:0;left:0;width:100%;height:100%;position:absolute;object-fit:cover;object-position:center}.freeze-images .videoStillImage{position:absolute;z-index:1;top:0;left:0;width:100%;height:100%;object-fit:cover}.freeze-images .progress-status{position:absolute;top:50%;left:50%;z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:auto;height:auto;margin:0;padding:10px;background-color:#ffffffb3;border:1px solid grey;transform:translate(-50%,-50%)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5301
+ }
5302
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FreezeFrameComponent, decorators: [{
5303
+ type: Component,
5304
+ args: [{ selector: 'app-freeze-frame', changeDetection: ChangeDetectionStrategy.OnPush, template: "@let image = combinedFreeze();\n@let progress = freezeFrameProgressMessageFromVideo();\n\n<div class=\"freeze-images\">\n @if (image) {\n <img [src]=\"image\" class=\"videoStillImage\" alt=\"freezeFrameImage\" />\n }\n\n @if (progress) {\n <div class=\"progress-status\">\n {{ progress }}\n </div>\n }\n</div>\n", styles: [".freeze-images{pointer-events:none;position:absolute;left:0;top:0;width:100%;height:100%;z-index:1}.freeze-images .freezeFrameOverlay{top:0;left:0;width:100%;height:100%;position:absolute;object-fit:cover;object-position:center}.freeze-images .videoStillImage{position:absolute;z-index:1;top:0;left:0;width:100%;height:100%;object-fit:cover}.freeze-images .progress-status{position:absolute;top:50%;left:50%;z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:auto;height:auto;margin:0;padding:10px;background-color:#ffffffb3;border:1px solid grey;transform:translate(-50%,-50%)}\n"] }]
5305
+ }] });
5306
+
5307
+ class SafePipe {
5308
+ constructor() {
5309
+ this.sanitizer = inject(DomSanitizer);
5534
5310
  }
5535
- trySetLowBandwidth(dataFlowResult) {
5536
- // Evaluate FPS and native quality
5537
- const isFpsLow = this.fps() < FilterModel.minimumFps;
5538
- const isNativeQualityRed = this.nativeQuality() === 'red';
5539
- const shouldSwitch = isNativeQualityRed || isFpsLow;
5540
- // Exit if conditions are not met or already in low bandwidth
5541
- if (!shouldSwitch || this.isLowBandwidth())
5542
- return;
5543
- const { quality, activeMedian } = dataFlowResult;
5544
- const isQualityRed = quality === 'red';
5545
- const isLowBitrate = activeMedian < FilterModel.minimumBitrate;
5546
- // Final check for a red drop or low bitrate, or low FPS
5547
- if (isQualityRed || isLowBitrate || isFpsLow) {
5548
- let reason;
5549
- if (isFpsLow) {
5550
- reason = `FPS < ${FilterModel.minimumFps}`;
5551
- }
5552
- else if (quality === 'red') {
5553
- reason = `Drop > ${FilterModel.redFlag}%`;
5554
- }
5555
- else {
5556
- reason = `Speed < ${FilterModel.minimumBitrate}`;
5557
- }
5558
- Logger.info(`LBM DETECTED: ${reason}, switching to low bandwidth; `, dataFlowResult.message);
5559
- this.store.dispatch(changeLowBandwidth({
5560
- lowBandwidth: true,
5561
- stats: {
5562
- dataFlowResult,
5563
- reason,
5564
- triggerTime: this.aggregatedStats()?.runTime ?? 0,
5565
- },
5566
- }));
5311
+ transform(value, type) {
5312
+ if (!value) {
5313
+ return value;
5567
5314
  }
5568
- }
5569
- toggleIndicator(value) {
5570
- this.isIndicatorExpanded.set(value);
5571
- if (value) {
5572
- this.hideIndicatorAfterDelay();
5315
+ switch (type) {
5316
+ case 'html':
5317
+ return this.sanitizer.bypassSecurityTrustHtml(value);
5318
+ case 'style':
5319
+ return this.sanitizer.bypassSecurityTrustStyle(value);
5320
+ case 'script':
5321
+ return this.sanitizer.bypassSecurityTrustScript(value);
5322
+ case 'url':
5323
+ return this.sanitizer.bypassSecurityTrustUrl(value);
5324
+ case 'resourceUrl':
5325
+ return this.sanitizer.bypassSecurityTrustResourceUrl(value);
5326
+ default:
5327
+ throw new Error(`Invalid safe type specified: ${type}`);
5573
5328
  }
5574
5329
  }
5575
- hideIndicatorAfterDelay() {
5576
- timer(3000)
5577
- .pipe(take(1), takeUntil$1(this.isIndicatorExpandedTrigger$))
5578
- .subscribe(() => this.toggleIndicator(false));
5330
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
5331
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, isStandalone: true, name: "safe" }); }
5332
+ }
5333
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SafePipe, decorators: [{
5334
+ type: Pipe,
5335
+ args: [{
5336
+ name: 'safe',
5337
+ }]
5338
+ }] });
5339
+
5340
+ class LowBandwidthModalComponent {
5341
+ constructor() {
5342
+ this.dialogRef = inject(DialogRef);
5579
5343
  }
5580
- openLBMDialog() {
5581
- this.dialog
5582
- .open(LowBandwidthModalComponent)
5583
- .closed.pipe(filter(Truthy))
5584
- .subscribe(() => {
5585
- this.toggleIndicator(false);
5586
- this.store.dispatch(changeLowBandwidth({ lowBandwidth: false }));
5587
- });
5344
+ close(value) {
5345
+ this.dialogRef.close(value || false);
5588
5346
  }
5589
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthDetectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5590
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: LowBandwidthDetectorComponent, isStandalone: true, selector: "app-low-bandwidth-detector", ngImport: i0, template: "@if (isReducedQuality() || isLowBandwidth()) {\n <div\n [class.expanded]=\"isIndicatorExpanded() && isLowBandwidth()\"\n class=\"lbm-indicator freeze-loader\"\n >\n <div (click)=\"toggleIndicator(true)\" class=\"lbm-icon\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 20 20\">\n <path\n fill=\"#85888E\"\n d=\"M7.189 3.605c-.73.145-1.438.35-2.126.614A14.412 14.412 0 0 0 .896 6.666a1.08 1.08 0 0 0-.375.844c0 .34.118.629.354.865s.524.36.865.375c.34.014.65-.09.927-.313a11.89 11.89 0 0 1 3.385-1.916 10.94 10.94 0 0 1 1.235-.375l-.098-2.541ZM7.385 8.708a9.107 9.107 0 0 0-2.906 1.48c-.264.194-.402.464-.416.812-.014.347.104.646.354.896.236.236.524.364.864.385.34.02.664-.073.97-.281.422-.29.878-.53 1.368-.721a2.545 2.545 0 0 1-.166-.814l-.068-1.757ZM12.386 11.267c.094-.25.15-.52.161-.802l.068-1.755a9.019 9.019 0 0 1 2.927 1.52c.264.193.4.46.406.801.007.34-.114.635-.364.885a1.213 1.213 0 0 1-.865.365A1.614 1.614 0 0 1 13.77 12a6.574 6.574 0 0 0-1.385-.733ZM12.713 6.146l.098-2.542c.73.146 1.438.351 2.127.615 1.541.59 2.93 1.406 4.166 2.447.25.223.379.5.386.834.007.333-.115.625-.365.875a1.253 1.253 0 0 1-.864.375c-.34.014-.65-.09-.927-.313a11.892 11.892 0 0 0-3.386-1.916 10.94 10.94 0 0 0-1.235-.375ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n />\n <path\n fill=\"#fff\"\n fill-rule=\"evenodd\"\n d=\"M10 .833c-.91 0-1.637.756-1.602 1.665l.304 7.92a1.299 1.299 0 0 0 2.596 0l.305-7.92A1.604 1.604 0 0 0 10 .833ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </div>\n\n <div\n [class.lbm-message--open]=\"isLowBandwidth() && isIndicatorExpanded()\"\n class=\"lbm-message\"\n >\n <p class=\"lbm-description\">\n Fluid Interactivity Modes were disabled due to an unstable connection.\n\n <button\n (click)=\"openLBMDialog()\"\n [attr.data-testid]=\"'learn-more-lbm'\"\n type=\"button\"\n class=\"lbm-learn-more\"\n >\n Learn more\n </button>\n </p>\n <button\n (click)=\"toggleIndicator(false)\"\n [attr.data-testid]=\"'close-lbm-indicator'\"\n type=\"button\"\n class=\"lbm-close\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n >\n <path\n d=\"M10.0001 11.2289L6.68618 14.5428C6.51951 14.7095 6.31818 14.7895 6.08218 14.7828C5.84618 14.7755 5.64485 14.6885 5.47818 14.5218C5.31151 14.3552 5.22818 14.1505 5.22818 13.9078C5.22818 13.6645 5.31151 13.4595 5.47818 13.2928L8.77107 9.99992L5.45718 6.68603C5.29051 6.51936 5.21051 6.3147 5.21718 6.07203C5.22451 5.8287 5.31151 5.6237 5.47818 5.45703C5.64485 5.29036 5.84951 5.20703 6.09218 5.20703C6.33551 5.20703 6.54051 5.29036 6.70718 5.45703L10.0001 8.77092L13.314 5.45703C13.4806 5.29036 13.6853 5.20703 13.928 5.20703C14.1713 5.20703 14.3763 5.29036 14.543 5.45703C14.7096 5.6237 14.793 5.8287 14.793 6.07203C14.793 6.3147 14.7096 6.51936 14.543 6.68603L11.2291 9.99992L14.543 13.3138C14.7096 13.4805 14.793 13.6818 14.793 13.9178C14.793 14.1538 14.7096 14.3552 14.543 14.5218C14.3763 14.6885 14.1713 14.7718 13.928 14.7718C13.6853 14.7718 13.4806 14.6885 13.314 14.5218L10.0001 11.2289Z\"\n fill=\"white\"\n />\n </svg>\n </button>\n </div>\n </div>\n}\n\n@if (isDevMode) {\n <app-filter-settings />\n}\n", styles: [".freeze-loader,.lbm-indicator{--lbmPositionTop: 18px;--lbmPositionLeft: 18px;--lbmPositionRight: 18px;--lbmIndicatorSize: 36px;--lbmIndicatorBorderRadius: 8px;--lbmIndicatorCloseSize: 20px;--lbmPadding: 10px;--lbmTextSize: 13px;--lbmLineHeight: 20px;--lbmTextColor: #ffffff;--lbmBackgroundColor: rgba(17, 24, 39, .7);position:absolute;top:var(--lbmPositionTop);left:var(--lbmPositionLeft);z-index:10;display:flex;justify-content:center;align-items:flex-start;min-width:var(--lbmIndicatorSize);min-height:var(--lbmIndicatorSize);overflow:hidden}.freeze-loader.expanded,.lbm-indicator.expanded{--lbmPositionTop: 8px;--lbmPositionLeft: 8px;--lbmPositionRight: 8px;width:auto;max-width:calc(100% - var(--lbmPositionLeft) - var(--lbmPositionRight));padding:var(--lbmPadding);background:var(--lbmBackgroundColor);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:var(--lbmIndicatorBorderRadius);gap:12px}.freeze-loader .lbm-icon,.lbm-indicator .lbm-icon{display:flex;width:var(--lbmIndicatorSize);height:var(--lbmIndicatorSize);padding:8px;color:#fff;background-color:var(--lbmBackgroundColor);border-radius:50%;flex-shrink:0;cursor:pointer}.freeze-loader .lbm-icon svg,.freeze-loader .lbm-icon img,.lbm-indicator .lbm-icon svg,.lbm-indicator .lbm-icon img{width:100%;height:100%}.freeze-loader .lbm-close,.lbm-indicator .lbm-close{position:absolute;top:0;right:0;width:var(--lbmIndicatorCloseSize);height:var(--lbmIndicatorCloseSize);background-color:transparent;border:none;padding:0;cursor:pointer}.freeze-loader .lbm-message,.lbm-indicator .lbm-message{position:relative;opacity:0;height:0;max-width:0;padding-right:24px;pointer-events:none;visibility:hidden;animation:closeMessage .3s forwards}.freeze-loader .lbm-message--open,.lbm-indicator .lbm-message--open{position:relative;visibility:visible;display:flex;align-items:center;align-self:center;pointer-events:all;gap:12px;max-width:fit-content;height:auto;opacity:1;animation:openMessage .3s forwards}.freeze-loader .lbm-description,.lbm-indicator .lbm-description{margin:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor)}.freeze-loader .lbm-learn-more,.lbm-indicator .lbm-learn-more{display:inline-flex;padding:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor);-webkit-text-decoration-line:underline;text-decoration-line:underline;background-color:transparent;border:none;cursor:pointer}.freeze-loader .lbm-learn-more:hover,.lbm-indicator .lbm-learn-more:hover{text-decoration:none}@keyframes openMessage{0%{opacity:0;max-width:0;height:0}10%{height:auto;max-width:fit-content}to{opacity:1;height:auto;max-width:fit-content}}@keyframes closeMessage{0%{opacity:1;max-width:fit-content}to{opacity:0;max-width:0}}\n"], dependencies: [{ kind: "component", type: FilterSettingsComponent, selector: "app-filter-settings" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5347
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5348
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: LowBandwidthModalComponent, isStandalone: true, selector: "app-low-bandwidth-modal", ngImport: i0, template: "<div [attr.data-testid]=\"'low-bandwidth'\" class=\"src-modal src-modal--lbm\">\n <header class=\"src-modal__header\">\n <h6 [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n Unstable Connection\n </h6>\n </header>\n <section class=\"src-modal__body\">\n <div\n [innerHtml]=\"\n 'Fluid Interactivity Modes were disabled due to an unstable connection. Showcase Gallery Mode is enabled. To regain full functionality, switch to Interactive Mode.'\n | safe: 'html'\n \"\n class=\"src-modal__scroll-box\"\n ></div>\n </section>\n <footer class=\"src-modal__footer\">\n <div class=\"src-modal__buttons\">\n <src-button\n (onClick)=\"close(true)\"\n [data-testid]=\"'switch-to-interactive-mode'\"\n >\n Switch\n </src-button>\n\n <src-button\n (onClick)=\"close()\"\n [data-testid]=\"'close-lbm-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </div>\n </footer>\n</div>\n", styles: [".src-modal--lbm{width:360px}.src-modal--lbm .src-modal__body{font-size:14px;line-height:24px}.src-modal--lbm .src-modal__buttons{display:flex;gap:8px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }, { kind: "pipe", type: SafePipe, name: "safe" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5591
5349
  }
5592
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthDetectorComponent, decorators: [{
5350
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthModalComponent, decorators: [{
5593
5351
  type: Component,
5594
- args: [{ selector: 'app-low-bandwidth-detector', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FilterSettingsComponent], template: "@if (isReducedQuality() || isLowBandwidth()) {\n <div\n [class.expanded]=\"isIndicatorExpanded() && isLowBandwidth()\"\n class=\"lbm-indicator freeze-loader\"\n >\n <div (click)=\"toggleIndicator(true)\" class=\"lbm-icon\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 20 20\">\n <path\n fill=\"#85888E\"\n d=\"M7.189 3.605c-.73.145-1.438.35-2.126.614A14.412 14.412 0 0 0 .896 6.666a1.08 1.08 0 0 0-.375.844c0 .34.118.629.354.865s.524.36.865.375c.34.014.65-.09.927-.313a11.89 11.89 0 0 1 3.385-1.916 10.94 10.94 0 0 1 1.235-.375l-.098-2.541ZM7.385 8.708a9.107 9.107 0 0 0-2.906 1.48c-.264.194-.402.464-.416.812-.014.347.104.646.354.896.236.236.524.364.864.385.34.02.664-.073.97-.281.422-.29.878-.53 1.368-.721a2.545 2.545 0 0 1-.166-.814l-.068-1.757ZM12.386 11.267c.094-.25.15-.52.161-.802l.068-1.755a9.019 9.019 0 0 1 2.927 1.52c.264.193.4.46.406.801.007.34-.114.635-.364.885a1.213 1.213 0 0 1-.865.365A1.614 1.614 0 0 1 13.77 12a6.574 6.574 0 0 0-1.385-.733ZM12.713 6.146l.098-2.542c.73.146 1.438.351 2.127.615 1.541.59 2.93 1.406 4.166 2.447.25.223.379.5.386.834.007.333-.115.625-.365.875a1.253 1.253 0 0 1-.864.375c-.34.014-.65-.09-.927-.313a11.892 11.892 0 0 0-3.386-1.916 10.94 10.94 0 0 0-1.235-.375ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n />\n <path\n fill=\"#fff\"\n fill-rule=\"evenodd\"\n d=\"M10 .833c-.91 0-1.637.756-1.602 1.665l.304 7.92a1.299 1.299 0 0 0 2.596 0l.305-7.92A1.604 1.604 0 0 0 10 .833ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </div>\n\n <div\n [class.lbm-message--open]=\"isLowBandwidth() && isIndicatorExpanded()\"\n class=\"lbm-message\"\n >\n <p class=\"lbm-description\">\n Fluid Interactivity Modes were disabled due to an unstable connection.\n\n <button\n (click)=\"openLBMDialog()\"\n [attr.data-testid]=\"'learn-more-lbm'\"\n type=\"button\"\n class=\"lbm-learn-more\"\n >\n Learn more\n </button>\n </p>\n <button\n (click)=\"toggleIndicator(false)\"\n [attr.data-testid]=\"'close-lbm-indicator'\"\n type=\"button\"\n class=\"lbm-close\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n >\n <path\n d=\"M10.0001 11.2289L6.68618 14.5428C6.51951 14.7095 6.31818 14.7895 6.08218 14.7828C5.84618 14.7755 5.64485 14.6885 5.47818 14.5218C5.31151 14.3552 5.22818 14.1505 5.22818 13.9078C5.22818 13.6645 5.31151 13.4595 5.47818 13.2928L8.77107 9.99992L5.45718 6.68603C5.29051 6.51936 5.21051 6.3147 5.21718 6.07203C5.22451 5.8287 5.31151 5.6237 5.47818 5.45703C5.64485 5.29036 5.84951 5.20703 6.09218 5.20703C6.33551 5.20703 6.54051 5.29036 6.70718 5.45703L10.0001 8.77092L13.314 5.45703C13.4806 5.29036 13.6853 5.20703 13.928 5.20703C14.1713 5.20703 14.3763 5.29036 14.543 5.45703C14.7096 5.6237 14.793 5.8287 14.793 6.07203C14.793 6.3147 14.7096 6.51936 14.543 6.68603L11.2291 9.99992L14.543 13.3138C14.7096 13.4805 14.793 13.6818 14.793 13.9178C14.793 14.1538 14.7096 14.3552 14.543 14.5218C14.3763 14.6885 14.1713 14.7718 13.928 14.7718C13.6853 14.7718 13.4806 14.6885 13.314 14.5218L10.0001 11.2289Z\"\n fill=\"white\"\n />\n </svg>\n </button>\n </div>\n </div>\n}\n\n@if (isDevMode) {\n <app-filter-settings />\n}\n", styles: [".freeze-loader,.lbm-indicator{--lbmPositionTop: 18px;--lbmPositionLeft: 18px;--lbmPositionRight: 18px;--lbmIndicatorSize: 36px;--lbmIndicatorBorderRadius: 8px;--lbmIndicatorCloseSize: 20px;--lbmPadding: 10px;--lbmTextSize: 13px;--lbmLineHeight: 20px;--lbmTextColor: #ffffff;--lbmBackgroundColor: rgba(17, 24, 39, .7);position:absolute;top:var(--lbmPositionTop);left:var(--lbmPositionLeft);z-index:10;display:flex;justify-content:center;align-items:flex-start;min-width:var(--lbmIndicatorSize);min-height:var(--lbmIndicatorSize);overflow:hidden}.freeze-loader.expanded,.lbm-indicator.expanded{--lbmPositionTop: 8px;--lbmPositionLeft: 8px;--lbmPositionRight: 8px;width:auto;max-width:calc(100% - var(--lbmPositionLeft) - var(--lbmPositionRight));padding:var(--lbmPadding);background:var(--lbmBackgroundColor);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:var(--lbmIndicatorBorderRadius);gap:12px}.freeze-loader .lbm-icon,.lbm-indicator .lbm-icon{display:flex;width:var(--lbmIndicatorSize);height:var(--lbmIndicatorSize);padding:8px;color:#fff;background-color:var(--lbmBackgroundColor);border-radius:50%;flex-shrink:0;cursor:pointer}.freeze-loader .lbm-icon svg,.freeze-loader .lbm-icon img,.lbm-indicator .lbm-icon svg,.lbm-indicator .lbm-icon img{width:100%;height:100%}.freeze-loader .lbm-close,.lbm-indicator .lbm-close{position:absolute;top:0;right:0;width:var(--lbmIndicatorCloseSize);height:var(--lbmIndicatorCloseSize);background-color:transparent;border:none;padding:0;cursor:pointer}.freeze-loader .lbm-message,.lbm-indicator .lbm-message{position:relative;opacity:0;height:0;max-width:0;padding-right:24px;pointer-events:none;visibility:hidden;animation:closeMessage .3s forwards}.freeze-loader .lbm-message--open,.lbm-indicator .lbm-message--open{position:relative;visibility:visible;display:flex;align-items:center;align-self:center;pointer-events:all;gap:12px;max-width:fit-content;height:auto;opacity:1;animation:openMessage .3s forwards}.freeze-loader .lbm-description,.lbm-indicator .lbm-description{margin:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor)}.freeze-loader .lbm-learn-more,.lbm-indicator .lbm-learn-more{display:inline-flex;padding:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor);-webkit-text-decoration-line:underline;text-decoration-line:underline;background-color:transparent;border:none;cursor:pointer}.freeze-loader .lbm-learn-more:hover,.lbm-indicator .lbm-learn-more:hover{text-decoration:none}@keyframes openMessage{0%{opacity:0;max-width:0;height:0}10%{height:auto;max-width:fit-content}to{opacity:1;height:auto;max-width:fit-content}}@keyframes closeMessage{0%{opacity:1;max-width:fit-content}to{opacity:0;max-width:0}}\n"] }]
5352
+ args: [{ selector: 'app-low-bandwidth-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SafePipe, SourceButtonComponent], template: "<div [attr.data-testid]=\"'low-bandwidth'\" class=\"src-modal src-modal--lbm\">\n <header class=\"src-modal__header\">\n <h6 [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n Unstable Connection\n </h6>\n </header>\n <section class=\"src-modal__body\">\n <div\n [innerHtml]=\"\n 'Fluid Interactivity Modes were disabled due to an unstable connection. Showcase Gallery Mode is enabled. To regain full functionality, switch to Interactive Mode.'\n | safe: 'html'\n \"\n class=\"src-modal__scroll-box\"\n ></div>\n </section>\n <footer class=\"src-modal__footer\">\n <div class=\"src-modal__buttons\">\n <src-button\n (onClick)=\"close(true)\"\n [data-testid]=\"'switch-to-interactive-mode'\"\n >\n Switch\n </src-button>\n\n <src-button\n (onClick)=\"close()\"\n [data-testid]=\"'close-lbm-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </div>\n </footer>\n</div>\n", styles: [".src-modal--lbm{width:360px}.src-modal--lbm .src-modal__body{font-size:14px;line-height:24px}.src-modal--lbm .src-modal__buttons{display:flex;gap:8px}\n"] }]
5595
5353
  }] });
5596
5354
 
5597
- /*
5598
- * Copyright (c) 2026.
5599
- * 3DSource.com. Sergii Karanda steve@3dsource.com. All Rights Reserved.
5600
- */
5601
- class WebRtcPlayerService2 extends SubService {
5355
+ class ImageLoadingSrcComponent {
5602
5356
  constructor() {
5603
- super();
5604
- this.onDataChannelMessage$ = new Subject();
5605
- this.signallingSrv = inject(SignallingService);
5606
- this.videoService = inject(VideoService);
5607
- this.commandTelemetryService = inject(CommandTelemetryService);
5608
- this.cfg = {};
5609
- this.useMic = false;
5610
- this.forceTURN = false;
5611
- this.forceMonoAudio = false;
5612
- /** Whether the remote SDP has been applied to the current peer connection */
5613
- this.remoteDescriptionSet = false;
5614
- /** ICE candidates received before the remote description was set */
5615
- this.iceCandidateBuffer = [];
5616
- this.isReconnecting = this.store.selectSignal(unrealFeature.selectIsReconnecting);
5617
- this.init();
5618
- this.actions$.pipe(ofType(dropDC)).subscribe(() => this.dcClient?.close());
5619
- this.actions$.pipe(ofType(dropPC)).subscribe(() => this.pcClient?.close());
5357
+ this.#store = inject(Store);
5358
+ this.isLoaderScreenVisible = this.#store.selectSignal(selectIsLoaderScreenVisible);
5359
+ this.imageLoadingSrc = this.#store.selectSignal(unrealFeature.selectImageLoadingSrc);
5620
5360
  }
5621
- init() {
5622
- this.signallingSrv.onConfig$.subscribe((config) => {
5623
- this.videoService.create();
5624
- this.setConfig(config);
5625
- });
5626
- // Server-initiated offer: set remote description and respond with answer
5627
- this.signallingSrv.onOffer$.pipe(filter(Truthy)).subscribe((offer) => {
5628
- void this.handleServerOffer(offer);
5629
- });
5630
- // Answer to a client-initiated offer
5631
- this.signallingSrv.onWebRtcAnswer$
5632
- .pipe(filter(Truthy))
5633
- .subscribe((answer) => {
5634
- void this.handleServerAnswer(answer);
5635
- });
5636
- // ICE candidates — buffered until remote description is set
5637
- this.signallingSrv.onWebRtcIce$.subscribe((candidate) => {
5638
- this.commandTelemetryService.trackStopCommand('EXT-iceCandidate', true, {
5639
- multi: true,
5640
- });
5641
- if (this.remoteDescriptionSet) {
5642
- this.handleCandidateFromServer(candidate);
5643
- }
5644
- else {
5645
- this.iceCandidateBuffer.push(candidate);
5646
- }
5361
+ #store;
5362
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageLoadingSrcComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5363
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: ImageLoadingSrcComponent, isStandalone: true, selector: "app-image-loading-src", ngImport: i0, template: "@let image = imageLoadingSrc();\n@if (isLoaderScreenVisible()) {\n <div class=\"preload\">\n <div class=\"back\">\n @if (image) {\n <img [ngSrc]=\"image\" fill loading=\"lazy\" alt=\"image loading src\" />\n }\n </div>\n </div>\n}\n", styles: [".preload{position:absolute;top:0;left:0;width:100%;height:100%;text-align:center}.preload .back{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%}.preload .back img{width:100%;height:100%;object-fit:cover}.preload .back{background-color:#fff}\n"], dependencies: [{ kind: "directive", type: NgOptimizedImage, selector: "img[ngSrc]", inputs: ["ngSrc", "ngSrcset", "sizes", "width", "height", "decoding", "loading", "priority", "loaderParams", "disableOptimizedSrcset", "fill", "placeholder", "placeholderConfig", "src", "srcset"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5364
+ }
5365
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ImageLoadingSrcComponent, decorators: [{
5366
+ type: Component,
5367
+ args: [{ selector: 'app-image-loading-src', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgOptimizedImage], template: "@let image = imageLoadingSrc();\n@if (isLoaderScreenVisible()) {\n <div class=\"preload\">\n <div class=\"back\">\n @if (image) {\n <img [ngSrc]=\"image\" fill loading=\"lazy\" alt=\"image loading src\" />\n }\n </div>\n </div>\n}\n", styles: [".preload{position:absolute;top:0;left:0;width:100%;height:100%;text-align:center}.preload .back{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%}.preload .back img{width:100%;height:100%;object-fit:cover}.preload .back{background-color:#fff}\n"] }]
5368
+ }] });
5369
+
5370
+ class StatGraphComponent {
5371
+ constructor() {
5372
+ this.label = input.required(...(ngDevMode ? [{ debugName: "label" }] : []));
5373
+ this.stat = input(...(ngDevMode ? [undefined, { debugName: "stat" }] : []));
5374
+ this.color = input('#417bc9', ...(ngDevMode ? [{ debugName: "color" }] : []));
5375
+ this.tickStep = input(3, ...(ngDevMode ? [{ debugName: "tickStep" }] : []));
5376
+ this.el = inject(ElementRef);
5377
+ this.graph = viewChild('graph', ...(ngDevMode ? [{ debugName: "graph" }] : []));
5378
+ this.data = [];
5379
+ this.max = signal(0, ...(ngDevMode ? [{ debugName: "max" }] : []));
5380
+ this.min = signal(0, ...(ngDevMode ? [{ debugName: "min" }] : []));
5381
+ this.current = signal(0, ...(ngDevMode ? [{ debugName: "current" }] : []));
5382
+ effect(() => this.pushData(this.stat()?.value ?? null));
5383
+ afterNextRender({
5384
+ read: () => {
5385
+ const graph = this.graph();
5386
+ if (graph) {
5387
+ this.canvas = graph.nativeElement;
5388
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5389
+ this.ctx = this.canvas.getContext('2d');
5390
+ }
5391
+ },
5647
5392
  });
5648
5393
  }
5649
- /**
5650
- * Handles an SDP offer received from the server.
5651
- * Creates a new peer connection, applies the remote offer,
5652
- * then responds with an SDP answer.
5653
- *
5654
- * The DataChannel is NOT created here — the server creates it
5655
- * and we receive it via the `datachannel` event on the peer connection.
5656
- */
5657
- async handleServerOffer(offer) {
5658
- Logger.info('Received offer from server, creating answer');
5659
- try {
5660
- this.remoteDescriptionSet = false;
5661
- this.iceCandidateBuffer.length = 0;
5662
- if (!this.pcClient) {
5663
- this.setupPeerConnection(this.cfg);
5664
- }
5665
- // Offer already defines media transceivers — don't add extras
5666
- await this.pcClient.setRemoteDescription(offer);
5667
- this.remoteDescriptionSet = true;
5668
- this.flushIceCandidateBuffer();
5669
- await this.createAnswer(this.pcClient);
5394
+ resize() {
5395
+ this.draw();
5396
+ }
5397
+ draw() {
5398
+ if (!this.canvas) {
5399
+ return;
5670
5400
  }
5671
- catch (e) {
5672
- Logger.error('Failed to handle server offer', e);
5673
- this.store.dispatch(disconnectStream({
5674
- reason: DisconnectReason.WebRTCError,
5675
- message: 'WebRTCError: Failed to handle server offer',
5676
- }));
5401
+ const all = this.data.filter((r) => r !== null && !isNaN(r));
5402
+ this.max.set(Math.max(...all));
5403
+ this.min.set(Math.min(...all));
5404
+ const scaleY = this.canvas.height / (this.max() || 1);
5405
+ this.current.set(all[all.length - 1] || 0);
5406
+ this.ctx.save();
5407
+ const bounds = this.el.nativeElement.getBoundingClientRect();
5408
+ this.canvas.width = bounds.width;
5409
+ this.canvas.height = bounds.height - 30; // -p height
5410
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
5411
+ [...this.data].reverse().forEach((val, index) => {
5412
+ const h = (val || 0) * scaleY;
5413
+ this.ctx.fillStyle = this.getColor(this.color(), h);
5414
+ this.ctx.fillRect(index * this.tickStep(), this.canvas.height - h, this.tickStep(), h);
5415
+ });
5416
+ this.ctx.restore();
5417
+ }
5418
+ getColor(fillStyle, value = 0) {
5419
+ const { r, g, b } = HEXtoRGB(fillStyle);
5420
+ /* eslint-disable-next-line prefer-const */
5421
+ let [h, s, v] = RGBtoHSV(r, g, b);
5422
+ v = inverseLerp(0, this.canvas.height, value);
5423
+ {
5424
+ const [r, g, b] = HSVtoRGB(h, s, v);
5425
+ return '#' + RGBtoHEX(r, g, b);
5677
5426
  }
5678
5427
  }
5679
- /**
5680
- * Handles an SDP answer received from the server in response to our offer.
5681
- * Applies the remote description and flushes any buffered ICE candidates.
5682
- */
5683
- async handleServerAnswer(answer) {
5684
- Logger.info('Received answer from server');
5685
- try {
5686
- await this.pcClient?.setRemoteDescription(answer);
5687
- this.remoteDescriptionSet = true;
5688
- this.flushIceCandidateBuffer();
5428
+ pushData(stat) {
5429
+ this.data.push(stat);
5430
+ if (!this.canvas) {
5431
+ return;
5689
5432
  }
5690
- catch (e) {
5691
- Logger.error('Failed to set remote description from answer', e);
5433
+ if (this.data.length > this.canvas.width / this.tickStep()) {
5434
+ this.data.shift();
5692
5435
  }
5436
+ this.draw();
5693
5437
  }
5694
- /** Drains buffered ICE candidates after the remote description has been set */
5695
- flushIceCandidateBuffer() {
5696
- for (const candidate of this.iceCandidateBuffer) {
5697
- this.handleCandidateFromServer(candidate);
5698
- }
5699
- this.iceCandidateBuffer.length = 0;
5438
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StatGraphComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5439
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.0", type: StatGraphComponent, isStandalone: true, selector: "app-stat-graph", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, stat: { classPropertyName: "stat", publicName: "stat", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, tickStep: { classPropertyName: "tickStep", publicName: "tickStep", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "window:resize": "resize()" } }, viewQueries: [{ propertyName: "graph", first: true, predicate: ["graph"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"content\">\n <p>{{ label() }}: {{ current() }} | Min:{{ min() }} | Max:{{ max() }}</p>\n <canvas #graph class=\"graph\"></canvas>\n</div>\n", styles: [".content{width:100%;height:100%}.content p{color:#fff;margin:5px 0 0;height:25px}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5440
+ }
5441
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: StatGraphComponent, decorators: [{
5442
+ type: Component,
5443
+ args: [{ selector: 'app-stat-graph', changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"content\">\n <p>{{ label() }}: {{ current() }} | Min:{{ min() }} | Max:{{ max() }}</p>\n <canvas #graph class=\"graph\"></canvas>\n</div>\n", styles: [".content{width:100%;height:100%}.content p{color:#fff;margin:5px 0 0;height:25px}\n"] }]
5444
+ }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], stat: [{ type: i0.Input, args: [{ isSignal: true, alias: "stat", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], tickStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "tickStep", required: false }] }], graph: [{ type: i0.ViewChild, args: ['graph', { isSignal: true }] }], resize: [{
5445
+ type: HostListener,
5446
+ args: ['window:resize']
5447
+ }] } });
5448
+
5449
+ class UnrealStatusComponent {
5450
+ constructor() {
5451
+ this.unrealInitialConfig = inject(UNREAL_CONFIG);
5452
+ this.destroyRef = inject(DestroyRef);
5453
+ this.store = inject(Store);
5454
+ this.resetPercentageSignal$ = this.store
5455
+ .select(selectShowLoader)
5456
+ .pipe(filter(Falsy));
5457
+ this.showLoader = this.store.selectSignal(selectShowLoader);
5458
+ /**
5459
+ * An observable that emits smoothed percentage values from 0 to 100.
5460
+ */
5461
+ this.messagePercents = toSignal(this.store.select(selectTotalProgress).pipe(keepMaxUntilReset(this.resetPercentageSignal$), floatToSmoothPercents(), distinctUntilChanged(), map$1((progress) => progress || 0)));
5462
+ this.awsInstance = this.store.selectSignal(unrealFeature.selectAwsInstance);
5463
+ }
5464
+ ngOnInit() {
5465
+ /**
5466
+ * A subject that emits null values to trigger nullification.
5467
+ */
5468
+ const nullify$ = new Subject();
5469
+ /**
5470
+ * An observable that emits null values with a delay of 2000 milliseconds.
5471
+ */
5472
+ const nullifyObs$ = nullify$
5473
+ .asObservable()
5474
+ .pipe(delay(2000));
5475
+ /**
5476
+ * An observable that emits the status message from the Unreal feature store.
5477
+ */
5478
+ const signalingStatus$ = this.store.select(unrealFeature.selectStatusMessage);
5479
+ /**
5480
+ * An observable that emits connection status messages based on the Cirrus connection state.
5481
+ * Then call side effect to nullify the message.
5482
+ */
5483
+ const cirrusConnected$ = this.store
5484
+ .select(unrealFeature.selectCirrusConnected)
5485
+ .pipe(skip(1), switchMap((connected) => connected
5486
+ ? of('Creating Socket...')
5487
+ : of('Connection closed.').pipe(tap(() => nullify$.next(null)))));
5488
+ /**
5489
+ * An observable that emits the viewport visibility state.
5490
+ */
5491
+ const viewportTrigger$ = this.store.select(unrealFeature.selectViewportReady);
5492
+ /**
5493
+ * An observable that will nullify any message displayed after 500 mSec, when viewport changes its state.
5494
+ */
5495
+ const streamToggle$ = merge(viewportTrigger$.pipe(filter(Falsy), skip(1)), viewportTrigger$.pipe(filter(Truthy))).pipe(delay(500), map$1(() => null));
5496
+ /**
5497
+ * A subscription that merges various status observables and emits debounced status via postMessage.
5498
+ */
5499
+ merge(nullifyObs$, cirrusConnected$, signalingStatus$, streamToggle$)
5500
+ .pipe(debounceTime$1(0), tapLog('STATUS=>'), takeUntilDestroyed(this.destroyRef))
5501
+ .subscribe((status) => this.sendMessageToIndex(status));
5700
5502
  }
5701
5503
  /**
5702
- * Creates a client-initiated WebRTC offer and sends it to the server.
5703
- * When the client offers, it also creates the DataChannel.
5704
- * Used for peer reconnection through the existing signaling connection.
5504
+ * Sends a message to the embedded iframe identified by the default value '3dsource_start_screen' ID.
5505
+ * This function tries to post a message to the iframe's content window. If the content
5506
+ * window is not accessible (e.g., due to cross-origin policies), the function fails silently.
5507
+ *
5508
+ * @param {string} text The message to be sent to the iframe. This could be a status update
5509
+ * or any other relevant information that the iframe needs to process.
5705
5510
  */
5706
- async createWebRtcOffer() {
5707
- Logger.info('Creating client-initiated offer');
5511
+ sendMessageToIndex(text) {
5512
+ const objectElement = document.getElementById(this.unrealInitialConfig?.screenLockerContainerId ||
5513
+ SCREEN_LOCKER_CONTAINER_ID);
5708
5514
  try {
5709
- this.remoteDescriptionSet = false;
5710
- this.iceCandidateBuffer.length = 0;
5711
- if (!this.pcClient) {
5712
- this.setupPeerConnection(this.cfg);
5713
- }
5714
- // Client offers: transceivers first, then DC, then offer
5715
- await this.setupTransceiversAsync(this.pcClient);
5716
- this.dcClient = this.createDataChannel(this.pcClient, 'UnrealDcConnection', { ordered: true });
5717
- await this.createOffer(this.pcClient);
5515
+ const objectDocument = objectElement.contentWindow;
5516
+ objectDocument?.postMessage(text, '*');
5718
5517
  }
5719
- catch (e) {
5720
- Logger.error('Failed to create WebRTC offer', e);
5721
- this.store.dispatch(disconnectStream({
5722
- reason: DisconnectReason.WebRTCError,
5723
- message: 'WebRTCError: Offer Error',
5724
- }));
5518
+ catch {
5519
+ // Fails silently if the iframe's content window cannot be accessed
5725
5520
  }
5726
5521
  }
5727
- /**
5728
- * Initializes a new RTCPeerConnection with event handlers.
5729
- * Transceivers are NOT added here — the caller is responsible for adding
5730
- * them at the correct point depending on the negotiation flow.
5731
- */
5732
- setupPeerConnection(config) {
5733
- this.closePC();
5734
- Logger.log('Creating new RTCPeerConnection with config');
5735
- this.pcClient = new RTCPeerConnection(config);
5736
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE gathering');
5737
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
5738
- this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
5739
- this.handlePeerConnectionEvents(this.pcClient);
5740
- this.disconnect$.pipe(take(1)).subscribe(() => {
5741
- this.closePC();
5522
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealStatusComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5523
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: UnrealStatusComponent, isStandalone: true, selector: "app-unreal-status", ngImport: i0, template: "@if (showLoader()) {\n <div class=\"status-box\">\n @if (awsInstance()?.message) {\n <p>{{ awsInstance()?.message }}</p>\n } @else {\n <div class=\"status-box__message\">\n <src-loading\n [size]=\"30\"\n [backgroundStrokeColor]=\"'#E5E7EB'\"\n [progressStrokeColor]=\"'#1F2937'\"\n />\n {{ messagePercents() }}%\n </div>\n }\n </div>\n}\n", styles: ["app-stat-graph{height:30px;width:100%}.status-box{position:absolute;top:80%;left:50%;transform:translate(-50%,-50%);z-index:5;display:flex;justify-content:center;align-items:center;width:auto;max-width:500px;margin:0;background:var(--src-surface-container-main, #fff);border:none;border-radius:var(--src-border-rounded-full, 9999px);box-shadow:0 8px 20px #1718181f,0 3px 6px #17181814;pointer-events:none}.status-box p{padding:10px 15px;margin:0;text-align:center}.status-box__message{display:flex;align-items:center;padding:12px 20px 12px 16px;color:var(--src-text-body-main, #1f2937);font-size:16px;font-weight:400;line-height:24px}.status-box__message .status-box__message_text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.status-box__message src-loading{margin-right:12px}\n"], dependencies: [{ kind: "component", type: SourceLoadingComponent, selector: "src-loading", inputs: ["size", "progress", "lineCap", "backgroundStrokeColor", "progressStrokeColor", "strokeWidth", "data-testid", "role", "ariaLabel", "ariaValueNow", "ariaValueMin", "ariaValueMax"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5524
+ }
5525
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealStatusComponent, decorators: [{
5526
+ type: Component,
5527
+ args: [{ selector: 'app-unreal-status', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceLoadingComponent], template: "@if (showLoader()) {\n <div class=\"status-box\">\n @if (awsInstance()?.message) {\n <p>{{ awsInstance()?.message }}</p>\n } @else {\n <div class=\"status-box__message\">\n <src-loading\n [size]=\"30\"\n [backgroundStrokeColor]=\"'#E5E7EB'\"\n [progressStrokeColor]=\"'#1F2937'\"\n />\n {{ messagePercents() }}%\n </div>\n }\n </div>\n}\n", styles: ["app-stat-graph{height:30px;width:100%}.status-box{position:absolute;top:80%;left:50%;transform:translate(-50%,-50%);z-index:5;display:flex;justify-content:center;align-items:center;width:auto;max-width:500px;margin:0;background:var(--src-surface-container-main, #fff);border:none;border-radius:var(--src-border-rounded-full, 9999px);box-shadow:0 8px 20px #1718181f,0 3px 6px #17181814;pointer-events:none}.status-box p{padding:10px 15px;margin:0;text-align:center}.status-box__message{display:flex;align-items:center;padding:12px 20px 12px 16px;color:var(--src-text-body-main, #1f2937);font-size:16px;font-weight:400;line-height:24px}.status-box__message .status-box__message_text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.status-box__message src-loading{margin-right:12px}\n"] }]
5528
+ }] });
5529
+
5530
+ class VideoStatsComponent {
5531
+ constructor() {
5532
+ this.collapsed = signal(false, ...(ngDevMode ? [{ debugName: "collapsed" }] : []));
5533
+ this.store = inject(Store);
5534
+ this.videoService = inject(VideoService);
5535
+ this.isDevMode = inject(DevModeService).isDevMode;
5536
+ this.obs$ = this.videoService.videoStats$;
5537
+ this.viewportReady = this.store.selectSignal(unrealFeature.selectViewportReady);
5538
+ this.ssInfo = this.store.selectSignal(selectSignalingParameters);
5539
+ this.fpsTick = toSignal(this.obs$.pipe(map$1((stat) => ({
5540
+ value: stat.aggregatedStats.framesPerSecond || 0,
5541
+ }))));
5542
+ this.fpsAvgTick = toSignal(this.obs$.pipe(map$1((stat) => ({
5543
+ value: stat.aggregatedStats.avgFrameRate,
5544
+ }))));
5545
+ this.bitrateTick = toSignal(this.obs$.pipe(map$1((stat) => ({
5546
+ value: stat.aggregatedStats.bitrate,
5547
+ }))));
5548
+ this.videoQP = toSignal(this.obs$.pipe(map$1((stat) => ({
5549
+ value: stat.aggregatedStats.VideoEncoderQP,
5550
+ }))));
5551
+ this.bitrateAverageTick = toSignal(this.obs$.pipe(map$1((stat) => ({
5552
+ value: stat.aggregatedStats.avgBitrate,
5553
+ }))));
5554
+ this.videoStatus = toSignal(this.obs$.pipe(map$1((event) => this.isDevMode &&
5555
+ {
5556
+ ...event.aggregatedStats,
5557
+ quality: event.quality,
5558
+ }), filter(Boolean), map$1((data) => {
5559
+ return this.fields
5560
+ .map((key) => {
5561
+ return {
5562
+ key,
5563
+ value: data[key],
5564
+ };
5565
+ })
5566
+ .filter((r) => !this.elementsToShow.includes(r.key));
5567
+ })));
5568
+ this.graphList = [];
5569
+ this.idKey = 'com.source3d.unreal.stat';
5570
+ this.elementsToShow = [];
5571
+ this.trigger$ = new BehaviorSubject(undefined);
5572
+ this.getFields();
5573
+ }
5574
+ ngOnInit() {
5575
+ this.fields = [
5576
+ 'kalmanBitrate',
5577
+ 'bitrateDrop',
5578
+ 'runTime',
5579
+ 'pixelRatio',
5580
+ 'frameHeight',
5581
+ 'frameWidth',
5582
+ ':-------------',
5583
+ 'jitter',
5584
+ 'MBytesReceived',
5585
+ 'framesDecoded',
5586
+ 'framesDropped',
5587
+ 'framesReceived',
5588
+ 'keyFramesDecoded',
5589
+ 'currentRoundTripTime',
5590
+ 'packetsLost',
5591
+ ':-------------',
5592
+ 'quality',
5593
+ 'videoCodec',
5594
+ 'kind',
5595
+ 'type',
5596
+ 'codecs',
5597
+ 'selectedPair',
5598
+ //'pliCount',
5599
+ //'sliCount',
5600
+ //'transportId',
5601
+ //'id',
5602
+ ];
5603
+ this.updateGraph();
5604
+ }
5605
+ updateGraph() {
5606
+ this.graphList = this.fields.map((key) => {
5607
+ const stat = this.obs$.pipe(map$1((val) => {
5608
+ const value = val.aggregatedStats[key];
5609
+ return { value };
5610
+ }));
5611
+ return {
5612
+ key,
5613
+ color: Math.floor(Math.random() * 16777216).toString(),
5614
+ stat,
5615
+ };
5742
5616
  });
5617
+ this.graphList$ = of(this.graphList).pipe(combineLatestWith(this.trigger$), map$1(([graphList]) => graphList.filter((r) => this.elementsToShow.includes(r.key))));
5743
5618
  }
5744
- createDataChannel(pc, label, options) {
5745
- const datachannel = pc.createDataChannel(label, options);
5746
- return this.setupDataChannelCallbacks(datachannel);
5619
+ toggle() {
5620
+ this.collapsed.update((collapsed) => !collapsed);
5621
+ this.writeFields();
5747
5622
  }
5748
- handleCandidateFromServer(iceCandidate) {
5749
- try {
5750
- const candidate = new RTCIceCandidate(iceCandidate);
5751
- Logger.info('[Unreal ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
5752
- // If forcing TURN, reject any candidates not relay
5753
- if (this.forceTURN) {
5754
- if (candidate.candidate.indexOf('relay') < 0) {
5755
- Logger.warn('Dropping candidate because it was not TURN relay.', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
5756
- return;
5757
- }
5758
- }
5759
- this.pcClient?.addIceCandidate(candidate).catch((e) => {
5760
- Logger.warn('Failed to add ICE candidate', e);
5761
- });
5762
- }
5763
- catch (e) {
5764
- Logger.error('Error handleCandidateFromServer', e);
5623
+ toggleGraph($event, el, value) {
5624
+ $event.stopPropagation();
5625
+ if ((value && typeof value !== 'number') ||
5626
+ (typeof value === 'number' && isNaN(value))) {
5627
+ return;
5765
5628
  }
5766
- }
5767
- /**
5768
- * Reorders the `m=video` line in the SDP so that payload types matching
5769
- * the given H.264 `profile-level-id` appear first, making the browser
5770
- * prefer that profile during codec negotiation.
5771
- *
5772
- * A `profile-level-id` is a 3-byte hex string (`XXYYZZ`) defined in
5773
- * RFC 6184 that encodes three H.264 parameters:
5774
- * - **Profile IDC** (`XX`) — the H.264 profile.
5775
- * - **Profile IOP** (`YY`) — constraint flags that further narrow the profile.
5776
- * - **Level IDC** (`ZZ`) — the level, which caps resolution/framerate/bitrate.
5777
- *
5778
- * ### H.264 profiles relevant to Unreal Engine Pixel Streaming
5779
- *
5780
- * | profile-level-id | Profile | Level | Notes |
5781
- * |------------------|----------------------------|-------|----------------------------------------------|
5782
- * | `42001f` | Baseline | 3.1 | No B-frames, low latency; legacy devices |
5783
- * | `42e01f` | Constrained Baseline | 3.1 | Widest HW decode support; **recommended** |
5784
- * | `4d001f` | Main | 3.1 | B-frames + CABAC; better compression |
5785
- * | `4d4028` | Main | 4.0 | Up to 1080p60; common desktop target |
5786
- * | `640028` | High | 4.0 | Best quality/compression; most GPUs support |
5787
- * | `64002a` | High | 4.2 | Up to 1080p120 or 4K30 |
5788
- * | `640033` | High | 5.1 | Up to 4K60; requires modern HW |
5789
- * | `640034` | High | 5.2 | Up to 4K120; high-end HW only |
5790
- * | `6e001f` | High 10 | 3.1 | 10-bit color; limited browser support |
5791
- * | `f4001f` | High 4:4:4 Predictive | 3.1 | Lossless capable; rare in browsers |
5792
- *
5793
- * UE Pixel Streaming defaults to Constrained Baseline (`42e01f`) or High (`6400xx`)
5794
- * depending on engine version and `WebRTC.H264Profile` CVar.
5795
- *
5796
- * We prioritize `42e01f` because it guarantees hardware decoding on
5797
- * virtually all client devices (desktop, mobile, embedded), avoiding
5798
- * fallback to software decoding or negotiation of a higher profile
5799
- * that the client GPU may not accelerate.
5800
- */
5801
- prioritizeH264Profile(sdp, profileLevelId) {
5802
- const lines = sdp.split('\r\n');
5803
- const targetPts = [];
5804
- for (const line of lines) {
5805
- if (line.startsWith('a=fmtp:') &&
5806
- line.toLowerCase().includes(`profile-level-id=${profileLevelId}`)) {
5807
- const pt = line.split(':')[1]?.split(' ')[0];
5808
- if (pt)
5809
- targetPts.push(pt);
5810
- }
5629
+ if (this.elementsToShow.includes(el)) {
5630
+ this.elementsToShow = this.elementsToShow.filter((item) => item !== el);
5811
5631
  }
5812
- if (targetPts.length === 0)
5813
- return sdp;
5814
- return lines
5815
- .map((line) => {
5816
- if (!line.startsWith('m=video'))
5817
- return line;
5818
- const parts = line.split(' ');
5819
- // parts: m=video <port> <proto> <pt1> <pt2> ...
5820
- const header = parts.slice(0, 3);
5821
- const pts = parts.slice(3);
5822
- const prioritized = [
5823
- ...targetPts.filter((pt) => pts.includes(pt)),
5824
- ...pts.filter((pt) => !targetPts.includes(pt)),
5825
- ];
5826
- return [...header, ...prioritized].join(' ');
5827
- })
5828
- .join('\r\n');
5829
- }
5830
- mungeSDP(sdp) {
5831
- let mungedSDP = this.prioritizeH264Profile(sdp, '42e01f');
5832
- mungedSDP = mungedSDP.replace(/(a=fmtp:\d+ .*level-asymmetry-allowed=.*)\r\n/gm, '$1;x-google-start-bitrate=10000;x-google-max-bitrate=100000\r\n');
5833
- // Set max bitrate to highest bitrate Opus supports
5834
- let audioSDP = 'maxaveragebitrate=510000;';
5835
- if (this.useMic) {
5836
- // Set the max capture rate to 48khz (so we can send high-quality audio from mic)
5837
- audioSDP += 'sprop-maxcapturerate=48000;';
5632
+ else {
5633
+ this.elementsToShow.push(el);
5838
5634
  }
5839
- // Force mono or stereo based on whether ?forceMono was passed or not
5840
- audioSDP += this.forceMonoAudio ? 'stereo=0;' : 'stereo=1;';
5841
- // Enable in-band forward error correction for opus audio
5842
- audioSDP += 'useinbandfec=1';
5843
- // We use the line 'useinbandfec=1' (which Opus uses) to set our Opus specific audio parameters.
5844
- mungedSDP = mungedSDP?.replace('useinbandfec=1', audioSDP);
5845
- return mungedSDP;
5635
+ this.writeFields();
5636
+ this.trigger$.next();
5846
5637
  }
5847
- /**
5848
- * Closes existing PeerConnection and DataChannel, resets negotiation state.
5849
- */
5850
- closePC() {
5851
- if (this.pcClient) {
5852
- Logger.warn('Closing existing PeerConnection');
5638
+ getFields() {
5639
+ try {
5640
+ const { elements, collapsed } = JSON.parse(localStorage.getItem(this.idKey)) || [];
5641
+ this.elementsToShow = elements || [];
5642
+ this.collapsed.set(collapsed);
5853
5643
  }
5854
- this.pcClient?.close();
5855
- this.pcClient = null;
5856
- if (this.dcClient) {
5857
- Logger.warn('Closing existing DataChannel');
5644
+ catch {
5645
+ this.elementsToShow = [];
5858
5646
  }
5859
- this.dcClient?.close();
5860
- this.dcClient = null;
5861
- this.remoteDescriptionSet = false;
5862
- this.iceCandidateBuffer.length = 0;
5863
5647
  }
5864
- canSend() {
5865
- return this.dcClient && this.dcClient.readyState === 'open';
5648
+ writeFields() {
5649
+ localStorage.setItem(this.idKey, JSON.stringify({
5650
+ collapsed: this.collapsed(),
5651
+ elements: this.elementsToShow,
5652
+ }));
5866
5653
  }
5867
- send(data) {
5868
- if (this.canSend()) {
5869
- this.dcClient?.send(data);
5654
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: VideoStatsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5655
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: VideoStatsComponent, isStandalone: true, selector: "app-video-stats", ngImport: i0, template: "@if (videoStatus() && viewportReady()) {\n <div class=\"settings-container\">\n <button (click)=\"toggle()\" class=\"gear-button\">Stats 2</button>\n <div (click)=\"toggle()\" [class.min]=\"collapsed()\" id=\"aggregatedStats\">\n <div class=\"forNerds\">\n <div (click)=\"$event.stopPropagation()\" class=\"static\">\n <li>\n <app-stat-graph\n [stat]=\"fpsTick()!\"\n label=\"FPS (higher is better)\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"videoQP()!\"\n label=\"QP (lower is better)\"\n color=\"#D5ff07\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"bitrateTick()!\"\n label=\"Bitrate\"\n color=\"#D57F07\"\n />\n </li>\n </div>\n <div>\n @for (graph of graphList$ | async; track graph) {\n <li (click)=\"toggleGraph($event, graph.key, 0)\" class=\"graph\">\n <app-stat-graph\n [label]=\"graph.key\"\n [stat]=\"(graph.stat | async)!\"\n [color]=\"graph.color\"\n />\n </li>\n }\n </div>\n </div>\n\n @for (el of videoStatus(); track el.key + $index) {\n <div (click)=\"toggleGraph($event, el.key, el.value)\" class=\"stat\">\n <span>{{ el.key }}: </span>{{ el.value | json }}\n </div>\n }\n </div>\n </div>\n @if (!collapsed()) {\n <div [innerHTML]=\"ssInfo() | safe: 'html'\" class=\"ssInfo\"></div>\n }\n}\n", styles: [".settings-container{position:absolute;top:65px;left:10px;z-index:1000;bottom:10px}.gear-button{left:0;width:50px;position:absolute;background:none;cursor:pointer;font-size:10px;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#fff;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}#aggregatedStats{max-height:calc(100% - 25px);overflow-y:auto;margin-top:25px;left:20px;z-index:31;max-width:400px;padding:10px;background-color:#fff3;border:1px solid grey}#aggregatedStats>*{color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000}#aggregatedStats:empty{display:none}#aggregatedStats button{position:absolute;right:5px;top:5px}#aggregatedStats.min{display:none}#aggregatedStats .forNerds{width:100%}#aggregatedStats .forNerds li{list-style-type:none;height:60px}#aggregatedStats .forNerds li app-stat-graph{display:block;width:100%;height:100%}#aggregatedStats .stat span{font-weight:700}#aggregatedStats .stat,#aggregatedStats .graph{cursor:pointer}#aggregatedStats .static{pointer-events:all}.ssInfo{position:absolute;top:0;left:50%;transform:translate(-50%);color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;text-align:center;width:80%;pointer-events:none}\n"], dependencies: [{ kind: "component", type: StatGraphComponent, selector: "app-stat-graph", inputs: ["label", "stat", "color", "tickStep"] }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: JsonPipe, name: "json" }, { kind: "pipe", type: SafePipe, name: "safe" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5656
+ }
5657
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: VideoStatsComponent, decorators: [{
5658
+ type: Component,
5659
+ args: [{ selector: 'app-video-stats', changeDetection: ChangeDetectionStrategy.OnPush, imports: [StatGraphComponent, AsyncPipe, JsonPipe, SafePipe], template: "@if (videoStatus() && viewportReady()) {\n <div class=\"settings-container\">\n <button (click)=\"toggle()\" class=\"gear-button\">Stats 2</button>\n <div (click)=\"toggle()\" [class.min]=\"collapsed()\" id=\"aggregatedStats\">\n <div class=\"forNerds\">\n <div (click)=\"$event.stopPropagation()\" class=\"static\">\n <li>\n <app-stat-graph\n [stat]=\"fpsTick()!\"\n label=\"FPS (higher is better)\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"videoQP()!\"\n label=\"QP (lower is better)\"\n color=\"#D5ff07\"\n />\n </li>\n <li>\n <app-stat-graph\n [stat]=\"bitrateTick()!\"\n label=\"Bitrate\"\n color=\"#D57F07\"\n />\n </li>\n </div>\n <div>\n @for (graph of graphList$ | async; track graph) {\n <li (click)=\"toggleGraph($event, graph.key, 0)\" class=\"graph\">\n <app-stat-graph\n [label]=\"graph.key\"\n [stat]=\"(graph.stat | async)!\"\n [color]=\"graph.color\"\n />\n </li>\n }\n </div>\n </div>\n\n @for (el of videoStatus(); track el.key + $index) {\n <div (click)=\"toggleGraph($event, el.key, el.value)\" class=\"stat\">\n <span>{{ el.key }}: </span>{{ el.value | json }}\n </div>\n }\n </div>\n </div>\n @if (!collapsed()) {\n <div [innerHTML]=\"ssInfo() | safe: 'html'\" class=\"ssInfo\"></div>\n }\n}\n", styles: [".settings-container{position:absolute;top:65px;left:10px;z-index:1000;bottom:10px}.gear-button{left:0;width:50px;position:absolute;background:none;cursor:pointer;font-size:10px;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#fff;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}#aggregatedStats{max-height:calc(100% - 25px);overflow-y:auto;margin-top:25px;left:20px;z-index:31;max-width:400px;padding:10px;background-color:#fff3;border:1px solid grey}#aggregatedStats>*{color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000}#aggregatedStats:empty{display:none}#aggregatedStats button{position:absolute;right:5px;top:5px}#aggregatedStats.min{display:none}#aggregatedStats .forNerds{width:100%}#aggregatedStats .forNerds li{list-style-type:none;height:60px}#aggregatedStats .forNerds li app-stat-graph{display:block;width:100%;height:100%}#aggregatedStats .stat span{font-weight:700}#aggregatedStats .stat,#aggregatedStats .graph{cursor:pointer}#aggregatedStats .static{pointer-events:all}.ssInfo{position:absolute;top:0;left:50%;transform:translate(-50%);color:#fff;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;text-align:center;width:80%;pointer-events:none}\n"] }]
5660
+ }], ctorParameters: () => [] });
5661
+
5662
+ class ResumeStreamButtonComponent {
5663
+ constructor() {
5664
+ this.actions$ = inject(Actions);
5665
+ this.store = inject(Store);
5666
+ this.videoService = inject(VideoService);
5667
+ this.destroyRef = inject(DestroyRef);
5668
+ this.showReconnectPopup = this.store.selectSignal(selectShowReconnectPopup);
5669
+ this.isLoaderScreenVisible = this.store.selectSignal(selectIsLoaderScreenVisible);
5670
+ this.streamConfig = this.store.selectSignal(selectStreamConfig);
5671
+ this.isExistMatchUrls = this.store.selectSignal(selectIsExistMatchUrls);
5672
+ this.isSecondStart = signal(!!(this.streamConfig()?.autoStart && location.href.match(/^https/gi)), ...(ngDevMode ? [{ debugName: "isSecondStart" }] : []));
5673
+ this.isShowResumeButton = computed(() => this.isLoaderScreenVisible() &&
5674
+ this.showReconnectPopup() &&
5675
+ this.isExistMatchUrls(), ...(ngDevMode ? [{ debugName: "isShowResumeButton" }] : []));
5676
+ }
5677
+ ngOnInit() {
5678
+ this.store
5679
+ .select(unrealFeature.selectCirrusConnected)
5680
+ .pipe(skip$1(1), filter(Falsy), takeUntilDestroyed(this.destroyRef))
5681
+ .subscribe(() => this.onDisconnect());
5682
+ }
5683
+ ngAfterViewInit() {
5684
+ this.actions$
5685
+ .pipe(ofType(videoStreamInitialized), takeUntilDestroyed(this.destroyRef), filter(() => !!this.videoService.video?.paused))
5686
+ .subscribe(() => {
5687
+ Logger.info('Simulate Press Play');
5688
+ this.videoService.play();
5689
+ this.store.dispatch(resetAfk());
5690
+ });
5691
+ }
5692
+ connect() {
5693
+ this.store.dispatch(startStream({ config: { autoStart: true } }));
5694
+ }
5695
+ onDisconnect() {
5696
+ this.isSecondStart.set(true);
5697
+ }
5698
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResumeStreamButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5699
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: ResumeStreamButtonComponent, isStandalone: true, selector: "app-resume-stream-button", ngImport: i0, template: "@if (isShowResumeButton()) {\n <div class=\"resume-box\">\n @if (isSecondStart()) {\n <div class=\"resume-box__text\">\n Your stream has been paused due to inactivity\n </div>\n }\n\n <src-button\n (onClick)=\"connect()\"\n [isFullWidth]=\"true\"\n [data-testid]=\"'connect-button'\"\n size=\"lg\"\n weight=\"primary\"\n class=\"connect-button\"\n >\n {{ isSecondStart() ? 'Resume' : 'Start' }}\n </src-button>\n </div>\n}\n", styles: [".resume-box{padding:16px;position:absolute;bottom:0;left:50%;gap:12px;z-index:6;display:flex;transform:translate(-50%,-50%);flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .connect-button{width:100%}.resume-box .resume-box__text{color:var(--src-color-grey-500, #6b7280);text-align:center;font-family:var(--src-font-family-body);font-size:14px;font-style:normal;font-weight:400;line-height:24px}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5700
+ }
5701
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: ResumeStreamButtonComponent, decorators: [{
5702
+ type: Component,
5703
+ args: [{ selector: 'app-resume-stream-button', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "@if (isShowResumeButton()) {\n <div class=\"resume-box\">\n @if (isSecondStart()) {\n <div class=\"resume-box__text\">\n Your stream has been paused due to inactivity\n </div>\n }\n\n <src-button\n (onClick)=\"connect()\"\n [isFullWidth]=\"true\"\n [data-testid]=\"'connect-button'\"\n size=\"lg\"\n weight=\"primary\"\n class=\"connect-button\"\n >\n {{ isSecondStart() ? 'Resume' : 'Start' }}\n </src-button>\n </div>\n}\n", styles: [".resume-box{padding:16px;position:absolute;bottom:0;left:50%;gap:12px;z-index:6;display:flex;transform:translate(-50%,-50%);flex-direction:column;align-items:center;border-radius:var(--src-border-rounded-parent, 8px);background:var(--src-surface-container-main, #fff);box-shadow:0 26px 80px #0003,0 0 1px #0003}.resume-box .connect-button{width:100%}.resume-box .resume-box__text{color:var(--src-color-grey-500, #6b7280);text-align:center;font-family:var(--src-font-family-body);font-size:14px;font-style:normal;font-weight:400;line-height:24px}\n"] }]
5704
+ }] });
5705
+
5706
+ class IntroSrcComponent {
5707
+ constructor() {
5708
+ this.videoElement = viewChild('videoPlayerCover', ...(ngDevMode ? [{ debugName: "videoElement" }] : []));
5709
+ this.store = inject(Store);
5710
+ this.imageIntroSrc = this.store.selectSignal(unrealFeature.selectImageIntroSrc);
5711
+ this.videoIntroSrc = this.store.selectSignal(unrealFeature.selectVideoIntroSrc);
5712
+ this.viewportReady = this.store.selectSignal(unrealFeature.selectViewportReady);
5713
+ effect(() => {
5714
+ const viewportReady = this.viewportReady();
5715
+ const videoIntroSrc = this.videoIntroSrc();
5716
+ const imageIntroSrc = this.imageIntroSrc();
5717
+ untracked(() => {
5718
+ if (viewportReady && (videoIntroSrc || imageIntroSrc)) {
5719
+ this.store.dispatch(resetIntroSrc());
5720
+ }
5721
+ });
5722
+ });
5723
+ effect(() => {
5724
+ const videoIntroSrc = this.videoIntroSrc();
5725
+ const videoElement = this.videoElement();
5726
+ if (!videoIntroSrc || !videoElement) {
5727
+ return;
5728
+ }
5729
+ untracked(() => this.createVideo(videoIntroSrc, videoElement.nativeElement));
5730
+ });
5731
+ }
5732
+ createVideo(src, videoContainer) {
5733
+ const matchAbsolutePath = !!src.match(/.*\.(mp4|WebM)(\?.*)?$/i);
5734
+ videoContainer.innerHTML = ''; // This will remove all existing content within the layout
5735
+ const videoElement = document.createElement('video');
5736
+ videoElement.autoplay = true;
5737
+ videoElement.muted = true;
5738
+ videoElement.loop = true;
5739
+ if (!matchAbsolutePath) {
5740
+ videoElement.poster = `${src}/cover.webp`;
5741
+ }
5742
+ videoElement.style.position = 'absolute';
5743
+ videoElement.style.width = '100%';
5744
+ videoElement.style.height = '100%';
5745
+ videoElement.style.objectFit = 'cover';
5746
+ const sourceElement = document.createElement('source');
5747
+ if (matchAbsolutePath) {
5748
+ sourceElement.src = src;
5870
5749
  }
5871
5750
  else {
5872
- Logger.colored('black', 'yellow', `DataChannel connection not prepared or in state: ${this.dcClient?.readyState}`);
5873
- // Trigger peer reconnection if DC is not open and we're not already reconnecting
5874
- this.tryReconnectOnSendFailure();
5751
+ sourceElement.src = `${src}/video.mp4`;
5875
5752
  }
5753
+ sourceElement.type = 'video/mp4';
5754
+ videoElement.appendChild(sourceElement);
5755
+ videoElement.innerHTML += 'Browser not supported';
5756
+ videoContainer.appendChild(videoElement);
5876
5757
  }
5877
- /**
5878
- * Attempts to reconnect the peer when send fails due to DataChannel not being open.
5879
- * This handles cases where the DC connection is broken but no error events are fired.
5880
- */
5881
- tryReconnectOnSendFailure() {
5882
- // Skip if already reconnecting or signaling is not connected
5883
- if (this.isReconnecting()) {
5884
- Logger.info('[Reconnect] Already reconnecting, skipping duplicate attempt');
5885
- return;
5886
- }
5887
- if (!this.signallingSrv.isConnected()) {
5888
- Logger.warn('[Reconnect] Cannot reconnect: Signaling not connected');
5889
- return;
5758
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: IntroSrcComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5759
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: IntroSrcComponent, isStandalone: true, selector: "app-intro-src", viewQueries: [{ propertyName: "videoElement", first: true, predicate: ["videoPlayerCover"], descendants: true, isSignal: true }], ngImport: i0, template: "@let image = imageIntroSrc();\n@if (image) {\n <div class=\"backImage\">\n <img [ngSrc]=\"image\" fill alt=\"imageSrc\" />\n </div>\n} @else if (videoIntroSrc()) {\n <div #videoPlayerCover class=\"backVideo\"></div>\n}\n", styles: [".backVideo{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backVideo video{position:absolute;width:100%;height:100%;object-fit:cover}.backImage{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backImage img{position:absolute;width:100%;height:100%;object-fit:cover}\n"], dependencies: [{ kind: "directive", type: NgOptimizedImage, selector: "img[ngSrc]", inputs: ["ngSrc", "ngSrcset", "sizes", "width", "height", "decoding", "loading", "priority", "loaderParams", "disableOptimizedSrcset", "fill", "placeholder", "placeholderConfig", "src", "srcset"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5760
+ }
5761
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: IntroSrcComponent, decorators: [{
5762
+ type: Component,
5763
+ args: [{ selector: 'app-intro-src', imports: [NgOptimizedImage], changeDetection: ChangeDetectionStrategy.OnPush, template: "@let image = imageIntroSrc();\n@if (image) {\n <div class=\"backImage\">\n <img [ngSrc]=\"image\" fill alt=\"imageSrc\" />\n </div>\n} @else if (videoIntroSrc()) {\n <div #videoPlayerCover class=\"backVideo\"></div>\n}\n", styles: [".backVideo{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backVideo video{position:absolute;width:100%;height:100%;object-fit:cover}.backImage{position:absolute;top:0;left:0;display:flex;justify-content:center;align-items:center;width:100%;height:100%;z-index:1}.backImage img{position:absolute;width:100%;height:100%;object-fit:cover}\n"] }]
5764
+ }], ctorParameters: () => [], propDecorators: { videoElement: [{ type: i0.ViewChild, args: ['videoPlayerCover', { isSignal: true }] }] } });
5765
+
5766
+ class UnrealSceneComponent {
5767
+ get resizeValues() {
5768
+ if (!this.isStudio()) {
5769
+ return this.getResizeValuesUniversal();
5890
5770
  }
5891
- // Check if DC exists but is in a non-open state (closing, closed, or connecting stuck)
5892
- const dcState = this.dcClient?.readyState;
5893
- if (dcState && dcState !== 'open') {
5894
- Logger.warn(`[Reconnect] DataChannel in "${dcState}" state during send, initiating peer reconnection`);
5895
- this.store.dispatch(reconnectPeer({ attempt: 1, maxAttempts: 3, delay: 0 }));
5771
+ return this.getResizeValues();
5772
+ }
5773
+ get pixelRatio() {
5774
+ return window.devicePixelRatio;
5775
+ }
5776
+ onMouseOver() {
5777
+ this.changeMouseOverScene.emit(true);
5778
+ }
5779
+ onMouseOut() {
5780
+ this.changeMouseOverScene.emit(false);
5781
+ }
5782
+ constructor() {
5783
+ this.isStudio = input(false, ...(ngDevMode ? [{ debugName: "isStudio" }] : []));
5784
+ this.useContainerAsSizeProvider = input(true, ...(ngDevMode ? [{ debugName: "useContainerAsSizeProvider" }] : []));
5785
+ this.studioResolutionSize = input({
5786
+ width: FULL_HD_WIDTH,
5787
+ height: FULL_HD_HEIGHT,
5788
+ }, ...(ngDevMode ? [{ debugName: "studioResolutionSize" }] : []));
5789
+ this.studioResolutionValues = toObservable(computed(() => {
5790
+ const values = this.studioResolutionSize();
5791
+ return { width: values.width, height: values.height };
5792
+ }));
5793
+ this.videoElement = viewChild('videoContainer', ...(ngDevMode ? [{ debugName: "videoElement" }] : []));
5794
+ this.changeMouseOverScene = output();
5795
+ this.width = FULL_HD_WIDTH;
5796
+ this.height = FULL_HD_HEIGHT;
5797
+ this.actions$ = inject(Actions);
5798
+ this.store = inject(Store);
5799
+ this.isExistMatchUrls = this.store.selectSignal(selectIsExistMatchUrls);
5800
+ this.isDevMode = inject(DevModeService).isDevMode;
5801
+ this.lightMode = this.store.selectSignal(unrealFeature.selectLowBandwidth);
5802
+ this.isFreezeFrameLoading = this.store.selectSignal(selectIsFreezeFrameLoading);
5803
+ this.commandsSender = inject(UnrealCommunicatorService);
5804
+ this.videoService = inject(VideoService);
5805
+ this.element = inject(ElementRef);
5806
+ this.destroyRef = inject(DestroyRef);
5807
+ this.destroyRef.onDestroy(() => this.store.dispatch(destroyUnrealScene()));
5808
+ effect(() => {
5809
+ const videoElement = this.videoElement();
5810
+ const isExistMatchUrls = this.isExistMatchUrls();
5811
+ if (videoElement && isExistMatchUrls) {
5812
+ this.store.dispatch(initSignalling());
5813
+ }
5814
+ });
5815
+ afterNextRender({
5816
+ read: () => {
5817
+ const videoElement = this.videoElement();
5818
+ if (videoElement) {
5819
+ this.videoService.setContainer(videoElement.nativeElement);
5820
+ if (this.element?.nativeElement) {
5821
+ fromEvent(this.element?.nativeElement, 'contextmenu')
5822
+ .pipe(takeUntilDestroyed(this.destroyRef))
5823
+ .subscribe((event) => event.preventDefault());
5824
+ }
5825
+ this.listenResizeValues();
5826
+ }
5827
+ },
5828
+ });
5829
+ }
5830
+ listenResizeValues() {
5831
+ const customSizeInputChanged$ = this.studioResolutionValues.pipe(filter(Truthy), distinctUntilChanged(), filter(({ width, height }) => this.width !== width || this.height !== height), map$1(({ width, height }) => ({ width, height })), tap(({ width, height }) => {
5832
+ this.width = width;
5833
+ this.height = height;
5834
+ }));
5835
+ const resizeRequired$ = merge(this.actions$.pipe(ofType(videoStreamInitialized)), fromEvent(window, 'resize'));
5836
+ const videoStreamSizeChanged$ = this.videoService?.videoStats$.pipe(filter(() => !this.isFreezeFrameLoading()), map$1((stats) => ({
5837
+ w: stats.aggregatedStats.frameWidth || 0,
5838
+ h: stats.aggregatedStats.frameHeight || 0,
5839
+ })), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5840
+ fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD)));
5841
+ merge(customSizeInputChanged$, resizeRequired$, videoStreamSizeChanged$)
5842
+ .pipe(debounceTime(DEBOUNCE_TO_MANY_RESIZE_CALLS), switchMap(() => {
5843
+ const key = Math.random();
5844
+ // Monitoring the size of the video stream
5845
+ return timer(0, 300).pipe(take(3), map$1(() => key));
5846
+ }), map$1((key) => ({ key, ...this.resizeValues })), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5847
+ fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD) &&
5848
+ previous.key === current.key), tap((size) => this.adaptVideo(size)), distinctUntilChanged((previous, current) => fpIsASameAsB(previous.w, current.w, SAME_SIZE_THRESHOLD) &&
5849
+ fpIsASameAsB(previous.h, current.h, SAME_SIZE_THRESHOLD)), combineLatestWith(this.store
5850
+ .select(unrealFeature.selectDataChannelConnected)
5851
+ .pipe(filter(Truthy))), takeUntilDestroyed(this.destroyRef))
5852
+ .subscribe(([size]) => {
5853
+ this.store.dispatch(changeStreamResolutionAction({ width: size.w, height: size.h }));
5854
+ this.sendResize(size);
5855
+ });
5856
+ this.actions$
5857
+ .pipe(ofType(forceResizeViewport), takeUntilDestroyed(this.destroyRef))
5858
+ .subscribe(() => this.sendResize(this.resizeValues));
5859
+ }
5860
+ adaptVideo(size) {
5861
+ const video = this.videoService.video;
5862
+ const areaSize = this.resizeValues;
5863
+ if (video?.style) {
5864
+ const pR = this.pixelRatio;
5865
+ video.width = this.makeEven(size.w / pR / areaSize.scale);
5866
+ video.height = this.makeEven(size.h / pR / areaSize.scale);
5867
+ if (!this.isStudio()) {
5868
+ video.style.overflow = 'hidden';
5869
+ video.style.objectFit = 'cover';
5870
+ video.style.width = `${video.width}px`;
5871
+ video.style.height = `${video.height}px`;
5872
+ }
5896
5873
  }
5874
+ this.store.dispatch(videoAdaptedToContainer());
5897
5875
  }
5898
5876
  /**
5899
- * Reconnects only the WebRTC peer connection without re-establishing signaling.
5900
- * Closes the current PeerConnection and creates a new client-initiated offer
5901
- * through the existing WebSocket connection.
5902
- * @returns true if reconnection was initiated, false if signaling is not connected
5877
+ * This method calculates the dimensions for resizing a layout in a configurator stream.
5878
+ * It takes into account the pixel ratio of the device's screen and the orientation of the layout.
5879
+ * The method uses the `fitIntoRectangle` function to calculate the dimensions that would allow the layout to fit into the maximum space while maintaining its aspect ratio.
5880
+ * It also sets the maximum limits for the width and height based on the layout's orientation.
5881
+ * If the scale of the fitted dimensions is less than 1, it assigns the fitted dimensions to the output. Otherwise, it sets the scale of the output to 1.
5882
+ * @returns {Area} An object of type `Area` that contains the calculated dimensions for resizing the layout.
5903
5883
  */
5904
- reconnectPeer() {
5905
- if (!this.signallingSrv.isConnected()) {
5906
- Logger.error('[Reconnect] Cannot reconnect peer: Signaling not connected');
5907
- return false;
5884
+ getResizeValuesUniversal() {
5885
+ const pR = this.pixelRatio;
5886
+ let containerW = window.innerWidth || 2;
5887
+ let containerH = window.innerHeight || 2;
5888
+ if (this.useContainerAsSizeProvider()) {
5889
+ containerW = this.videoElement()?.nativeElement.offsetWidth || 2;
5890
+ containerH = this.videoElement()?.nativeElement.offsetHeight || 2;
5908
5891
  }
5909
- if (!this.cfg || Object.keys(this.cfg).length === 0) {
5910
- Logger.error('[Reconnect] Cannot reconnect peer: No config available');
5911
- return false;
5892
+ const maxSpace = { w: containerW * pR, h: containerH * pR };
5893
+ const isVertical = containerW < containerH;
5894
+ let maxH = FULL_HD_HEIGHT;
5895
+ let maxW = FULL_HD_WIDTH;
5896
+ const params = new URLSearchParams(location.search);
5897
+ const maxRes = params.get('maxresolution');
5898
+ if (Truthy(maxRes)) {
5899
+ maxH = FULL_HD_HEIGHT * 2;
5900
+ maxW = FULL_HD_WIDTH * 2;
5912
5901
  }
5913
- Logger.info('[Reconnect] Initiating peer reconnection');
5914
- // Close only the PeerConnection, keep WebSocket alive
5915
- this.closePC();
5916
- // Create a new WebRTC offer using the stored config
5917
- void this.createWebRtcOffer();
5918
- return true;
5919
- }
5920
- setConfig(parOptions = {}) {
5921
- const urlParams = new URLSearchParams(window.location.search);
5922
- //**********************
5923
- //Config setup
5924
- //**********************
5925
- this.cfg = { ...parOptions.peerConnectionOptions };
5926
- this.forceTURN = urlParams.has('ForceTURN');
5927
- if (this.forceTURN) {
5928
- Logger.info('Forcing TURN usage by setting ICE Transport Policy in peer connection config.');
5929
- this.cfg.iceTransportPolicy = 'relay';
5902
+ const maxLimits = isVertical
5903
+ ? { w: maxH, h: maxW, x: 0, y: 0 }
5904
+ : { w: maxW, h: maxH, x: 0, y: 0 };
5905
+ const props = fitIntoRectangle({ x: 0, y: 0, w: containerW, h: containerH }, { x: 0, y: 0, ...maxSpace });
5906
+ let out = { ...props };
5907
+ const fitIntoMax = fitIntoRectangle(props, maxLimits);
5908
+ if (fitIntoMax.scale < 1) {
5909
+ // If reducing size to 1920x1080 is required, do it
5910
+ out = fitIntoMax;
5930
5911
  }
5931
- this.cfg.bundlePolicy = urlParams.has('ForceMaxBundle')
5932
- ? 'max-bundle'
5933
- : 'balanced';
5934
- this.cfg.iceCandidatePoolSize = 1;
5935
- // When ?useMic check for SSL or localhost
5936
- const isLocalhostConnection = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
5937
- const isHttpsConnection = location.protocol === 'https:';
5938
- if (this.useMic && !isLocalhostConnection && !isHttpsConnection) {
5939
- this.useMic = false;
5940
- Logger.error('Microphone access in the browser will not work if you are not on HTTPS or localhost. Disabling mic access.');
5941
- Logger.error("For testing you can enable HTTP microphone access Chrome by visiting chrome://flags/ and enabling 'unsafely-treat-insecure-origin-as-secure'");
5912
+ else {
5913
+ // Otherwise, use the lowest resolution because the layout size is smaller than 1920x1080
5914
+ out.scale = 1;
5942
5915
  }
5943
- this.setupPeerConnection(this.cfg);
5916
+ /**
5917
+ * Ensure that the size is always even.
5918
+ */
5919
+ out.w = this.makeEven(out.w);
5920
+ out.h = this.makeEven(out.h);
5921
+ return { ...out };
5944
5922
  }
5945
- handlePeerConnectionEvents(pcClient) {
5946
- fromEvent(pcClient, 'signalingstatechange')
5947
- .pipe(map$1(() => this.pcClient?.signalingState), takeUntil(this.disconnect$))
5948
- .subscribe((state) => {
5949
- Logger.info('[Signaling state change] |', state, '|');
5950
- this.commandTelemetryService.trackStopCommand('EXT-Signaling state change', true, { state, multi: true });
5951
- });
5952
- fromEvent(pcClient, 'iceconnectionstatechange')
5953
- .pipe(map$1(() => this.pcClient?.iceConnectionState), takeUntil(this.disconnect$))
5954
- .subscribe((state) => {
5955
- Logger.info('[Browser ICE connection] |', state, '|');
5956
- this.commandTelemetryService.trackStopCommand('EXT-Browser ICE connection', true, { state, multi: true });
5957
- });
5958
- fromEvent(pcClient, 'icegatheringstatechange')
5959
- .pipe(map$1(() => this.pcClient?.iceGatheringState), takeUntil(this.disconnect$))
5960
- .subscribe((state) => {
5961
- Logger.info('[Browser ICE gathering] |', state, '|');
5962
- this.commandTelemetryService.trackStopCommand('EXT-Browser ICE gathering', true, { state, multi: true });
5963
- });
5964
- fromEvent(pcClient, 'connectionstatechange')
5965
- .pipe(map$1(() => this.pcClient?.connectionState), takeUntil(this.disconnect$))
5966
- .subscribe((state) => {
5967
- Logger.info('[RTCPeerConnection state] |', state, '|');
5968
- });
5969
- fromEvent(pcClient, 'icecandidate')
5970
- .pipe(map$1((e) => e.candidate), filter(Truthy), tap$1((candidate) => Logger.info('[Browser ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|')), takeUntil(this.disconnect$))
5971
- .subscribe((candidate) => this.signallingSrv.send({ type: 'iceCandidate', candidate }));
5972
- // When the server creates the DataChannel (server-offer flow),
5973
- // we receive it here via the 'datachannel' event
5974
- fromEvent(pcClient, 'datachannel')
5975
- .pipe(tap$1((e) => Logger.info(`[DATACHANNEL] Received remote DC: label="${e.channel.label}" id=${e.channel.id} protocol="${e.channel.protocol}" ordered=${e.channel.ordered}`)), map$1((e) => e.channel), takeUntil(this.disconnect$))
5976
- .subscribe((datachannel) => {
5977
- this.dcClient = this.setupDataChannelCallbacks(datachannel);
5923
+ getResizeValues() {
5924
+ const pR = this.pixelRatio;
5925
+ const containerW = pR * (this.videoElement()?.nativeElement.offsetWidth || 2);
5926
+ const containerH = pR * (this.videoElement()?.nativeElement.offsetHeight || 2);
5927
+ const maxSpace = { w: containerW, h: containerH };
5928
+ const videoSizePixelPerfect = fitIntoRectangle({ x: 0, y: 0, w: this.width, h: this.height }, { x: 0, y: 0, ...maxSpace });
5929
+ const out = fitIntoRectangle(videoSizePixelPerfect, {
5930
+ x: 0,
5931
+ y: 0,
5932
+ w: Math.min(containerW, FULL_HD_WIDTH),
5933
+ h: Math.min(containerH, FULL_HD_HEIGHT),
5978
5934
  });
5979
- const onTrack$ = fromEvent(pcClient, 'track').pipe(takeUntil(this.disconnect$), filter((e) => !!e.track), tap$1((e) => Logger.info('Got track - ' +
5980
- e.track.kind +
5981
- ' id=' +
5982
- e.track.id +
5983
- ' readyState=' +
5984
- e.track.readyState)), share());
5985
- onTrack$
5986
- .pipe(filter((e) => e.track.kind === 'audio'), map$1((e) => e.streams[0]))
5987
- .subscribe((stream) => this.videoService.attachAudioStream(stream));
5988
- onTrack$
5989
- .pipe(filter((e) => e.track.kind === 'video'), map$1((e) => e.streams[0]), tap$1(() => Logger.info('Set video source from video track ontrack.')))
5990
- .subscribe((stream) => {
5991
- if (this.pcClient) {
5992
- this.videoService.attachVideoStream(stream, this.pcClient);
5993
- }
5935
+ /**
5936
+ * Ensure that the size is always even.
5937
+ */
5938
+ out.w = this.makeEven(out.w);
5939
+ out.h = this.makeEven(out.h);
5940
+ return out;
5941
+ }
5942
+ sendResize(size) {
5943
+ this.commandsSender.sendCommandToUnreal({
5944
+ command: MetaBoxCommand.FChangeResolutionCommand,
5945
+ payload: { resolution: { x: size.w, y: size.h } },
5994
5946
  });
5995
5947
  }
5996
- async setupTransceiversAsync(pc) {
5997
- const hasTransceivers = pc.getTransceivers().length > 0;
5998
- // Set up a transceiver for getting UE video
5999
- pc.addTransceiver('video', { direction: 'recvonly' });
6000
- // Set up a transceiver for sending mic audio to UE and receiving audio from UE
6001
- if (!this.useMic) {
6002
- pc.addTransceiver('audio', { direction: 'recvonly' });
6003
- }
6004
- else {
6005
- const audioSendOptions = this.useMic
6006
- ? {
6007
- autoGainControl: false,
6008
- channelCount: 1,
6009
- echoCancellation: false,
6010
- latency: 0,
6011
- noiseSuppression: false,
6012
- sampleRate: 48000,
6013
- sampleSize: 16,
6014
- volume: 1.0,
6015
- }
6016
- : false;
6017
- // Note using mic on android chrome requires SSL or chrome://flags/ "unsafely-treat-insecure-origin-as-secure"
6018
- const stream = await navigator.mediaDevices.getUserMedia({
6019
- video: false,
6020
- audio: audioSendOptions,
6021
- });
6022
- if (stream) {
6023
- if (hasTransceivers) {
6024
- for (const transceiver of pc.getTransceivers()) {
6025
- if (transceiver &&
6026
- transceiver.receiver &&
6027
- transceiver.receiver.track &&
6028
- transceiver.receiver.track.kind === 'audio') {
6029
- for (const track of stream.getTracks()) {
6030
- if (track.kind && track.kind === 'audio') {
6031
- await transceiver.sender.replaceTrack(track);
6032
- transceiver.direction = 'sendrecv';
6033
- }
6034
- }
6035
- }
6036
- }
6037
- }
6038
- else {
6039
- for (const track of stream.getTracks()) {
6040
- if (track.kind && track.kind === 'audio') {
6041
- pc.addTransceiver(track, { direction: 'sendrecv' });
6042
- }
6043
- }
6044
- }
6045
- }
6046
- else {
6047
- pc.addTransceiver('audio', { direction: 'recvonly' });
5948
+ makeEven(value) {
5949
+ return Math.floor((value + 1) / 2) * 2;
5950
+ }
5951
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealSceneComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5952
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: UnrealSceneComponent, isStandalone: true, selector: "app-unreal-scene", inputs: { isStudio: { classPropertyName: "isStudio", publicName: "isStudio", isSignal: true, isRequired: false, transformFunction: null }, useContainerAsSizeProvider: { classPropertyName: "useContainerAsSizeProvider", publicName: "useContainerAsSizeProvider", isSignal: true, isRequired: false, transformFunction: null }, studioResolutionSize: { classPropertyName: "studioResolutionSize", publicName: "studioResolutionSize", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { changeMouseOverScene: "changeMouseOverScene" }, host: { listeners: { "mouseover": "onMouseOver()", "mouseout": "onMouseOut()" } }, viewQueries: [{ propertyName: "videoElement", first: true, predicate: ["videoContainer"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"frame unreal-container\">\n <div #videoContainer [class.lightModeCursor]=\"lightMode()\" id=\"player\"></div>\n\n <app-intro-src />\n <app-image-loading-src />\n <app-resume-stream-button />\n <app-unreal-status />\n <app-freeze-frame />\n <app-afk-timeout-modal />\n\n <ng-content select=\"app-pdf\"></ng-content>\n <ng-content select=\"app-show-case\"></ng-content>\n <ng-content select=\"app-low-bandwidth-detector\"></ng-content>\n\n @if (isDevMode) {\n <app-video-stats />\n }\n</div>\n", styles: [".unreal-container{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#fff}.unreal-container #player{position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:center;overflow:hidden}.unreal-container.select{cursor:url('data:image/svg+xml,<svg width=\"24\" height=\"24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M15.15 21.375a1.423 1.423 0 0 1-1.15.063 1.487 1.487 0 0 1-.85-.788l-3-6.45-2.325 3.25c-.283.4-.658.525-1.125.375-.467-.15-.7-.466-.7-.95V4.05c0-.417.188-.717.563-.9.375-.183.729-.142 1.062.125l10.1 7.95c.383.284.496.65.337 1.1-.158.45-.479.675-.962.675h-4.2l2.975 6.375c.183.384.204.767.063 1.15a1.487 1.487 0 0 1-.788.85Z\" fill=\"%23000\"/><path d=\"m12.697 20.861.002.005c.237.495.617.852 1.128 1.04.515.191 1.039.16 1.539-.08a1.987 1.987 0 0 0 1.04-1.128 1.922 1.922 0 0 0-.079-1.536v-.003L13.684 13.5H17.1c.32 0 .628-.075.89-.26.263-.184.438-.447.544-.749.106-.3.135-.617.04-.925a1.458 1.458 0 0 0-.545-.738L7.936 2.883a1.51 1.51 0 0 0-.768-.336 1.471 1.471 0 0 0-.825.154 1.495 1.495 0 0 0-.626.547 1.492 1.492 0 0 0-.217.802v12.825c0 .324.08.634.272.897.193.261.467.43.775.53.31.099.632.12.942.016.31-.103.555-.313.743-.578h.001l1.825-2.552 2.639 5.673Z\" stroke=\"%23fff\" stroke-opacity=\".7\"/></svg>') 6 2,auto}.unreal-container.orbit{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.998 21.6c-1.966 0-3.737-.53-5.312-1.588-1.575-1.058-2.738-2.437-3.488-4.137a.852.852 0 0 1 .013-.713.867.867 0 0 1 .512-.487.86.86 0 0 1 .688.037c.225.109.387.28.487.513.617 1.35 1.559 2.45 2.825 3.3 1.267.85 2.692 1.275 4.275 1.275 1.384 0 2.646-.33 3.788-.988a7.985 7.985 0 0 0 2.762-2.612h-.85a.873.873 0 0 1-.64-.257.863.863 0 0 1-.26-.638.88.88 0 0 1 .26-.643.864.864 0 0 1 .64-.262h3c.255 0 .47.086.642.259a.87.87 0 0 1 .258.64v3a.874.874 0 0 1-.257.642.863.863 0 0 1-.637.259.88.88 0 0 1-.643-.259.865.865 0 0 1-.263-.641v-.725a9.95 9.95 0 0 1-3.35 2.925c-1.35.733-2.833 1.1-4.45 1.1Zm0-17.4c-1.383 0-2.645.33-3.787.987A7.985 7.985 0 0 0 5.448 7.8h.85c.255 0 .47.086.642.257a.863.863 0 0 1 .258.638.88.88 0 0 1-.258.642.864.864 0 0 1-.642.263h-3a.87.87 0 0 1-.64-.259.87.87 0 0 1-.26-.641v-3c0-.255.086-.469.258-.641a.863.863 0 0 1 .637-.26.88.88 0 0 1 .643.26.864.864 0 0 1 .262.64v.726A9.95 9.95 0 0 1 7.548 3.5c1.35-.733 2.834-1.1 4.45-1.1 2 0 3.788.542 5.363 1.625 1.575 1.083 2.736 2.49 3.482 4.22a.724.724 0 0 1-.045.646.926.926 0 0 1-.525.437.87.87 0 0 1-.687-.04.977.977 0 0 1-.488-.513c-.616-1.35-1.558-2.45-2.825-3.3-1.266-.85-2.691-1.275-4.275-1.275Zm0 10.8a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 8.998 12c0-.833.292-1.542.875-2.125A2.893 2.893 0 0 1 11.998 9c.834 0 1.542.292 2.125.875.584.583.875 1.292.875 2.125s-.291 1.542-.875 2.125a2.893 2.893 0 0 1-2.125.875Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"m2.739 16.072.002.005c.789 1.788 2.014 3.24 3.666 4.35 1.66 1.116 3.53 1.673 5.591 1.673 1.696 0 3.262-.386 4.69-1.16a10.54 10.54 0 0 0 2.714-2.097c.069.167.171.32.308.454.272.268.613.403.994.403.38 0 .722-.135.992-.406s.402-.614.402-.994v-3c0-.381-.134-.724-.405-.995a1.37 1.37 0 0 0-.995-.405h-3c-.383 0-.727.137-.997.411a1.38 1.38 0 0 0-.403.994c0 .38.135.723.407.992.238.237.532.369.858.397a7.552 7.552 0 0 1-2.027 1.685c-1.062.613-2.237.92-3.538.92-1.488 0-2.815-.397-3.996-1.19-1.19-.798-2.07-1.826-2.647-3.087-.147-.34-.393-.6-.727-.76a1.36 1.36 0 0 0-1.073-.058c-.37.133-.64.397-.8.754a1.351 1.351 0 0 0-.016 1.114Zm4.553-8.37a1.36 1.36 0 0 0-.858-.396A7.552 7.552 0 0 1 8.46 5.62c1.062-.613 2.237-.921 3.537-.921 1.489 0 2.816.398 3.997 1.19 1.19.8 2.07 1.827 2.646 3.088.148.34.394.6.728.76a1.37 1.37 0 0 0 1.068.063c.347-.12.62-.346.799-.668.187-.338.215-.71.072-1.072l-.003-.007-.003-.007c-.784-1.818-2.006-3.298-3.658-4.434C15.982 2.47 14.094 1.9 11.998 1.9c-1.695 0-3.261.385-4.688 1.16a10.536 10.536 0 0 0-2.715 2.096 1.364 1.364 0 0 0-.308-.453 1.38 1.38 0 0 0-.994-.403c-.38 0-.722.135-.992.406s-.403.614-.403.994v3c0 .38.135.724.406.995.27.27.614.405.994.405h3c.384 0 .727-.137.998-.412a1.38 1.38 0 0 0 .402-.993c0-.38-.135-.723-.406-.992ZM9.52 14.48a3.393 3.393 0 0 0 2.478 1.02c.965 0 1.8-.342 2.479-1.02a3.392 3.392 0 0 0 1.021-2.48c0-.963-.343-1.8-1.021-2.478A3.392 3.392 0 0 0 11.998 8.5c-.964 0-1.8.343-2.478 1.021A3.392 3.392 0 0 0 8.498 12c0 .964.344 1.8 1.022 2.479Z\"/></svg>') 12 12,auto}.unreal-container.pan{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.997 10.075a1.01 1.01 0 0 1-.738-.313 1.008 1.008 0 0 1-.312-.737V6.15l-.95.95a.998.998 0 0 1-.763.312 1.09 1.09 0 0 1-.763-.337 1.06 1.06 0 0 1-.324-.775c0-.3.108-.558.324-.775l2.776-2.775c.117-.116.237-.2.362-.25a1.04 1.04 0 0 1 .775 0c.125.05.246.134.363.25l2.8 2.8c.216.217.32.47.313.762a1.096 1.096 0 0 1-.338.763 1.058 1.058 0 0 1-.775.325c-.3 0-.558-.108-.775-.325l-.925-.925v2.875c0 .283-.104.53-.312.737a1.01 1.01 0 0 1-.738.313Zm0 11.5a1.04 1.04 0 0 1-.388-.075 1.103 1.103 0 0 1-.362-.25l-2.8-2.8a1.002 1.002 0 0 1-.313-.762c.009-.292.121-.546.337-.763.217-.217.475-.325.775-.325.3 0 .559.108.775.325l.926.925v-2.875c0-.283.104-.529.312-.737a1.01 1.01 0 0 1 .738-.313c.283 0 .529.104.738.313.208.208.312.454.312.737v2.875l.95-.95a.998.998 0 0 1 .762-.312c.292.008.546.12.763.337.216.217.324.475.324.775 0 .3-.108.559-.324.775l-2.775 2.775c-.117.116-.238.2-.363.25a1.04 1.04 0 0 1-.387.075Zm4.925-6.05a1.058 1.058 0 0 1-.326-.774c0-.3.109-.559.326-.776l.925-.925H14.97c-.283 0-.528-.104-.736-.312a1.01 1.01 0 0 1-.313-.738c0-.283.104-.53.313-.738.208-.208.453-.312.737-.312h2.875l-.95-.95a.998.998 0 0 1-.313-.762 1.09 1.09 0 0 1 .338-.764 1.06 1.06 0 0 1 .775-.324c.3 0 .558.108.775.324l2.774 2.776c.117.117.2.238.251.363a1.04 1.04 0 0 1 0 .775c-.05.124-.134.245-.25.362l-2.8 2.8c-.217.216-.471.32-.762.313a1.096 1.096 0 0 1-.763-.337Zm-11.4 0L2.746 12.75a1.103 1.103 0 0 1-.25-.362 1.04 1.04 0 0 1 0-.775c.05-.125.133-.246.25-.363l2.8-2.8c.216-.216.47-.32.762-.313.292.01.546.121.763.337.217.217.325.476.325.776 0 .3-.108.558-.325.775l-.925.925h2.875c.283 0 .529.104.737.312.208.209.313.455.313.738s-.105.53-.313.738a1.008 1.008 0 0 1-.737.312H6.147l.95.95c.217.217.32.471.312.762a1.09 1.09 0 0 1-.337.764 1.06 1.06 0 0 1-.775.324c-.3 0-.559-.108-.776-.324Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M9.22 7.912c.436.013.821-.15 1.13-.458l.097-.097v1.668c0 .422.16.793.458 1.09.298.299.67.46 1.092.46.422 0 .793-.161 1.091-.46.298-.297.459-.668.459-1.09V7.357l.071.072c.309.308.693.471 1.13.471.435 0 .82-.163 1.128-.471.301-.303.47-.676.483-1.102v-.001a1.501 1.501 0 0 0-.46-1.13l-2.799-2.8a1.6 1.6 0 0 0-.528-.36l-.003-.001a1.54 1.54 0 0 0-1.145 0l-.002.001a1.6 1.6 0 0 0-.529.36L8.118 5.172l-.001.001a1.56 1.56 0 0 0-.47 1.128c0 .435.163.82.47 1.128.303.303.676.472 1.103.484Zm0 0 .014-.5-.015.5Zm-1.311 6.865a1.497 1.497 0 0 0-.458-1.13l-.097-.097h1.668c.421 0 .793-.16 1.09-.458.298-.299.46-.67.46-1.092a1.51 1.51 0 0 0-.46-1.091 1.507 1.507 0 0 0-1.09-.459H7.354l.071-.072c.309-.308.472-.692.472-1.128 0-.436-.163-.82-.472-1.13a1.596 1.596 0 0 0-1.101-.482h-.002a1.501 1.501 0 0 0-1.129.459l-2.8 2.8a1.6 1.6 0 0 0-.36.528l-.001.003a1.54 1.54 0 0 0 0 1.144v.003c.081.2.208.375.362.529l2.774 2.775c.309.308.693.47 1.129.47.435 0 .82-.162 1.128-.47.303-.302.472-.676.484-1.102Zm0 0-.5-.014.5.015v-.001Zm3.038-8.627-.5.5.5.207V6.15Zm.475 15.814.002.001a1.54 1.54 0 0 0 1.145 0h.002c.2-.081.375-.208.53-.362l2.775-2.774a1.56 1.56 0 0 0 .47-1.129c0-.435-.162-.82-.47-1.128a1.589 1.589 0 0 0-1.103-.484 1.497 1.497 0 0 0-1.13.458l-.096.097v-1.668c0-.421-.161-.793-.459-1.09a1.509 1.509 0 0 0-1.091-.46c-.422 0-.794.161-1.092.46a1.507 1.507 0 0 0-.458 1.09v1.668l-.072-.072a1.558 1.558 0 0 0-1.129-.471c-.436 0-.82.163-1.128.471l-.001.001c-.301.302-.47.675-.483 1.101v.001c-.012.437.151.821.46 1.13l2.8 2.8c.153.153.328.28.528.36Zm5.146-6.085c.303.302.676.47 1.102.484a1.5 1.5 0 0 0 1.13-.46l2.8-2.8a1.6 1.6 0 0 0 .36-.528l.002-.003a1.54 1.54 0 0 0 0-1.144l-.001-.003a1.6 1.6 0 0 0-.361-.529l-2.774-2.775h-.001a1.56 1.56 0 0 0-1.128-.47c-.436 0-.82.162-1.128.47h-.001a1.59 1.59 0 0 0-.484 1.102c-.012.437.15.822.459 1.13l.097.097h-1.668c-.422 0-.793.16-1.09.459-.299.298-.46.67-.46 1.091 0 .422.161.793.46 1.092.297.297.668.458 1.09.458h1.668l-.072.072a1.558 1.558 0 0 0-.472 1.129c0 .435.164.82.472 1.128ZM6.147 13.05l.5.5.207-.5h-.707Z\"/></svg>') 12 12,auto}.unreal-container.dolly{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><mask id=\"a\" width=\"24\" height=\"24\" x=\"0\" y=\"0\" maskUnits=\"userSpaceOnUse\" style=\"mask-type:alpha\"><path fill=\"%23127392\" d=\"M0 0h24v24H0z\"/></mask><g mask=\"url(%23a)\"><path fill=\"%23000\" d=\"M12.002 20.575a1.026 1.026 0 0 1-.75-.325l-2.55-2.55a.933.933 0 0 1-.287-.713c.008-.275.113-.513.313-.713.2-.2.441-.3.725-.3.283 0 .524.1.724.3l.776.776V6.95l-.8.8c-.2.2-.438.296-.712.288a1.018 1.018 0 0 1-.713-.312c-.2-.2-.3-.442-.3-.726s.1-.525.3-.725l2.524-2.525a1.026 1.026 0 0 1 1.5 0l2.55 2.55c.2.2.296.438.289.713a1.023 1.023 0 0 1-.314.713c-.2.2-.441.3-.724.3a.988.988 0 0 1-.725-.3l-.775-.776v10.1l.8-.8c.2-.2.437-.296.712-.288.275.008.512.112.712.312.2.2.3.442.3.726s-.1.525-.3.725l-2.524 2.525a1.026 1.026 0 0 1-.75.325Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M13.553 8.153c.28.245.62.373 1 .373.415 0 .784-.153 1.078-.447.287-.287.446-.644.46-1.05v-.002a1.432 1.432 0 0 0-.435-1.08l-2.55-2.55-.354.353.354-.354a1.526 1.526 0 0 0-2.207 0L8.374 5.921A1.487 1.487 0 0 0 7.928 7c0 .416.152.785.446 1.08.288.287.645.446 1.052.457l4.127-.384Zm0 0v7.693m0-7.693a1.667 1.667 0 0 1-.079-.074l-3.021.075v7.693a1.482 1.482 0 0 0-1-.373c-.416 0-.785.153-1.079.447a1.522 1.522 0 0 0-.46 1.05v.002c-.011.417.14.786.435 1.08l2.55 2.55a1.527 1.527 0 0 0 2.207 0l2.525-2.524c.294-.294.446-.663.446-1.079 0-.416-.152-.785-.446-1.08a1.518 1.518 0 0 0-1.052-.457m0 0-.014.5.014-.5Zm0 0a1.424 1.424 0 0 0-1.027.383m0 0a1.583 1.583 0 0 0-.053.05l.354.354m-.3-.404v.104l.3.3m0 0-.3-.3v.6l.3-.3Z\"/></g></svg>%0A') 12 12,auto}.unreal-container.disabled:before{content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}#playerUI{position:absolute;z-index:10;width:100%}#statsContainer{display:none;text-align:left;background-color:#000c}#stats{padding:6px;font-weight:700;font-size:14px;color:#0f0}canvas{position:absolute;image-rendering:crisp-edges}#overlay{position:absolute;top:0;right:2%;z-index:100;padding:4px;border-top-width:0;-webkit-border-bottom-right-radius:5px;-moz-border-radius-bottomright:5px;border-bottom-right-radius:5px;-webkit-border-bottom-left-radius:5px;-moz-border-radius-bottomleft:5px;border-bottom-left-radius:5px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none}.overlay{font-weight:lighter;font-family:var(--src-font-family-body)}#overlayButton:hover{cursor:pointer}#overlayButton{font-size:40px;text-align:right}#overlaySettings{display:none;width:300px;padding:4px}#videoMessageOverlay{position:absolute;z-index:20;width:100%;margin:auto;font-size:1.8em;font-family:var(--src-font-family-body)}#playButton{display:inline-block;height:auto}img#playButton{width:10%;max-width:241px}#UIInteraction{position:fixed}#UIInteractionButtonBoundary{padding:2px}#UIInteractionButton{cursor:pointer}#hiddenInput{position:absolute;left:-10%;width:0;opacity:0}#editTextButton{position:absolute;width:40px;height:40px}\n"], dependencies: [{ kind: "component", type: AfkTimeoutModalComponent, selector: "app-afk-timeout-modal" }, { kind: "component", type: UnrealStatusComponent, selector: "app-unreal-status" }, { kind: "component", type: FreezeFrameComponent, selector: "app-freeze-frame" }, { kind: "component", type: VideoStatsComponent, selector: "app-video-stats" }, { kind: "component", type: ResumeStreamButtonComponent, selector: "app-resume-stream-button" }, { kind: "component", type: ImageLoadingSrcComponent, selector: "app-image-loading-src" }, { kind: "component", type: IntroSrcComponent, selector: "app-intro-src" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5953
+ }
5954
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: UnrealSceneComponent, decorators: [{
5955
+ type: Component,
5956
+ args: [{ selector: 'app-unreal-scene', changeDetection: ChangeDetectionStrategy.OnPush, imports: [
5957
+ AfkTimeoutModalComponent,
5958
+ UnrealStatusComponent,
5959
+ FreezeFrameComponent,
5960
+ VideoStatsComponent,
5961
+ ResumeStreamButtonComponent,
5962
+ ImageLoadingSrcComponent,
5963
+ IntroSrcComponent,
5964
+ ], template: "<div class=\"frame unreal-container\">\n <div #videoContainer [class.lightModeCursor]=\"lightMode()\" id=\"player\"></div>\n\n <app-intro-src />\n <app-image-loading-src />\n <app-resume-stream-button />\n <app-unreal-status />\n <app-freeze-frame />\n <app-afk-timeout-modal />\n\n <ng-content select=\"app-pdf\"></ng-content>\n <ng-content select=\"app-show-case\"></ng-content>\n <ng-content select=\"app-low-bandwidth-detector\"></ng-content>\n\n @if (isDevMode) {\n <app-video-stats />\n }\n</div>\n", styles: [".unreal-container{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#fff}.unreal-container #player{position:absolute;width:100%;height:100%;display:flex;justify-content:center;align-items:center;overflow:hidden}.unreal-container.select{cursor:url('data:image/svg+xml,<svg width=\"24\" height=\"24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M15.15 21.375a1.423 1.423 0 0 1-1.15.063 1.487 1.487 0 0 1-.85-.788l-3-6.45-2.325 3.25c-.283.4-.658.525-1.125.375-.467-.15-.7-.466-.7-.95V4.05c0-.417.188-.717.563-.9.375-.183.729-.142 1.062.125l10.1 7.95c.383.284.496.65.337 1.1-.158.45-.479.675-.962.675h-4.2l2.975 6.375c.183.384.204.767.063 1.15a1.487 1.487 0 0 1-.788.85Z\" fill=\"%23000\"/><path d=\"m12.697 20.861.002.005c.237.495.617.852 1.128 1.04.515.191 1.039.16 1.539-.08a1.987 1.987 0 0 0 1.04-1.128 1.922 1.922 0 0 0-.079-1.536v-.003L13.684 13.5H17.1c.32 0 .628-.075.89-.26.263-.184.438-.447.544-.749.106-.3.135-.617.04-.925a1.458 1.458 0 0 0-.545-.738L7.936 2.883a1.51 1.51 0 0 0-.768-.336 1.471 1.471 0 0 0-.825.154 1.495 1.495 0 0 0-.626.547 1.492 1.492 0 0 0-.217.802v12.825c0 .324.08.634.272.897.193.261.467.43.775.53.31.099.632.12.942.016.31-.103.555-.313.743-.578h.001l1.825-2.552 2.639 5.673Z\" stroke=\"%23fff\" stroke-opacity=\".7\"/></svg>') 6 2,auto}.unreal-container.orbit{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.998 21.6c-1.966 0-3.737-.53-5.312-1.588-1.575-1.058-2.738-2.437-3.488-4.137a.852.852 0 0 1 .013-.713.867.867 0 0 1 .512-.487.86.86 0 0 1 .688.037c.225.109.387.28.487.513.617 1.35 1.559 2.45 2.825 3.3 1.267.85 2.692 1.275 4.275 1.275 1.384 0 2.646-.33 3.788-.988a7.985 7.985 0 0 0 2.762-2.612h-.85a.873.873 0 0 1-.64-.257.863.863 0 0 1-.26-.638.88.88 0 0 1 .26-.643.864.864 0 0 1 .64-.262h3c.255 0 .47.086.642.259a.87.87 0 0 1 .258.64v3a.874.874 0 0 1-.257.642.863.863 0 0 1-.637.259.88.88 0 0 1-.643-.259.865.865 0 0 1-.263-.641v-.725a9.95 9.95 0 0 1-3.35 2.925c-1.35.733-2.833 1.1-4.45 1.1Zm0-17.4c-1.383 0-2.645.33-3.787.987A7.985 7.985 0 0 0 5.448 7.8h.85c.255 0 .47.086.642.257a.863.863 0 0 1 .258.638.88.88 0 0 1-.258.642.864.864 0 0 1-.642.263h-3a.87.87 0 0 1-.64-.259.87.87 0 0 1-.26-.641v-3c0-.255.086-.469.258-.641a.863.863 0 0 1 .637-.26.88.88 0 0 1 .643.26.864.864 0 0 1 .262.64v.726A9.95 9.95 0 0 1 7.548 3.5c1.35-.733 2.834-1.1 4.45-1.1 2 0 3.788.542 5.363 1.625 1.575 1.083 2.736 2.49 3.482 4.22a.724.724 0 0 1-.045.646.926.926 0 0 1-.525.437.87.87 0 0 1-.687-.04.977.977 0 0 1-.488-.513c-.616-1.35-1.558-2.45-2.825-3.3-1.266-.85-2.691-1.275-4.275-1.275Zm0 10.8a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 8.998 12c0-.833.292-1.542.875-2.125A2.893 2.893 0 0 1 11.998 9c.834 0 1.542.292 2.125.875.584.583.875 1.292.875 2.125s-.291 1.542-.875 2.125a2.893 2.893 0 0 1-2.125.875Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"m2.739 16.072.002.005c.789 1.788 2.014 3.24 3.666 4.35 1.66 1.116 3.53 1.673 5.591 1.673 1.696 0 3.262-.386 4.69-1.16a10.54 10.54 0 0 0 2.714-2.097c.069.167.171.32.308.454.272.268.613.403.994.403.38 0 .722-.135.992-.406s.402-.614.402-.994v-3c0-.381-.134-.724-.405-.995a1.37 1.37 0 0 0-.995-.405h-3c-.383 0-.727.137-.997.411a1.38 1.38 0 0 0-.403.994c0 .38.135.723.407.992.238.237.532.369.858.397a7.552 7.552 0 0 1-2.027 1.685c-1.062.613-2.237.92-3.538.92-1.488 0-2.815-.397-3.996-1.19-1.19-.798-2.07-1.826-2.647-3.087-.147-.34-.393-.6-.727-.76a1.36 1.36 0 0 0-1.073-.058c-.37.133-.64.397-.8.754a1.351 1.351 0 0 0-.016 1.114Zm4.553-8.37a1.36 1.36 0 0 0-.858-.396A7.552 7.552 0 0 1 8.46 5.62c1.062-.613 2.237-.921 3.537-.921 1.489 0 2.816.398 3.997 1.19 1.19.8 2.07 1.827 2.646 3.088.148.34.394.6.728.76a1.37 1.37 0 0 0 1.068.063c.347-.12.62-.346.799-.668.187-.338.215-.71.072-1.072l-.003-.007-.003-.007c-.784-1.818-2.006-3.298-3.658-4.434C15.982 2.47 14.094 1.9 11.998 1.9c-1.695 0-3.261.385-4.688 1.16a10.536 10.536 0 0 0-2.715 2.096 1.364 1.364 0 0 0-.308-.453 1.38 1.38 0 0 0-.994-.403c-.38 0-.722.135-.992.406s-.403.614-.403.994v3c0 .38.135.724.406.995.27.27.614.405.994.405h3c.384 0 .727-.137.998-.412a1.38 1.38 0 0 0 .402-.993c0-.38-.135-.723-.406-.992ZM9.52 14.48a3.393 3.393 0 0 0 2.478 1.02c.965 0 1.8-.342 2.479-1.02a3.392 3.392 0 0 0 1.021-2.48c0-.963-.343-1.8-1.021-2.478A3.392 3.392 0 0 0 11.998 8.5c-.964 0-1.8.343-2.478 1.021A3.392 3.392 0 0 0 8.498 12c0 .964.344 1.8 1.022 2.479Z\"/></svg>') 12 12,auto}.unreal-container.pan{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><path fill=\"%23000\" d=\"M11.997 10.075a1.01 1.01 0 0 1-.738-.313 1.008 1.008 0 0 1-.312-.737V6.15l-.95.95a.998.998 0 0 1-.763.312 1.09 1.09 0 0 1-.763-.337 1.06 1.06 0 0 1-.324-.775c0-.3.108-.558.324-.775l2.776-2.775c.117-.116.237-.2.362-.25a1.04 1.04 0 0 1 .775 0c.125.05.246.134.363.25l2.8 2.8c.216.217.32.47.313.762a1.096 1.096 0 0 1-.338.763 1.058 1.058 0 0 1-.775.325c-.3 0-.558-.108-.775-.325l-.925-.925v2.875c0 .283-.104.53-.312.737a1.01 1.01 0 0 1-.738.313Zm0 11.5a1.04 1.04 0 0 1-.388-.075 1.103 1.103 0 0 1-.362-.25l-2.8-2.8a1.002 1.002 0 0 1-.313-.762c.009-.292.121-.546.337-.763.217-.217.475-.325.775-.325.3 0 .559.108.775.325l.926.925v-2.875c0-.283.104-.529.312-.737a1.01 1.01 0 0 1 .738-.313c.283 0 .529.104.738.313.208.208.312.454.312.737v2.875l.95-.95a.998.998 0 0 1 .762-.312c.292.008.546.12.763.337.216.217.324.475.324.775 0 .3-.108.559-.324.775l-2.775 2.775c-.117.116-.238.2-.363.25a1.04 1.04 0 0 1-.387.075Zm4.925-6.05a1.058 1.058 0 0 1-.326-.774c0-.3.109-.559.326-.776l.925-.925H14.97c-.283 0-.528-.104-.736-.312a1.01 1.01 0 0 1-.313-.738c0-.283.104-.53.313-.738.208-.208.453-.312.737-.312h2.875l-.95-.95a.998.998 0 0 1-.313-.762 1.09 1.09 0 0 1 .338-.764 1.06 1.06 0 0 1 .775-.324c.3 0 .558.108.775.324l2.774 2.776c.117.117.2.238.251.363a1.04 1.04 0 0 1 0 .775c-.05.124-.134.245-.25.362l-2.8 2.8c-.217.216-.471.32-.762.313a1.096 1.096 0 0 1-.763-.337Zm-11.4 0L2.746 12.75a1.103 1.103 0 0 1-.25-.362 1.04 1.04 0 0 1 0-.775c.05-.125.133-.246.25-.363l2.8-2.8c.216-.216.47-.32.762-.313.292.01.546.121.763.337.217.217.325.476.325.776 0 .3-.108.558-.325.775l-.925.925h2.875c.283 0 .529.104.737.312.208.209.313.455.313.738s-.105.53-.313.738a1.008 1.008 0 0 1-.737.312H6.147l.95.95c.217.217.32.471.312.762a1.09 1.09 0 0 1-.337.764 1.06 1.06 0 0 1-.775.324c-.3 0-.559-.108-.776-.324Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M9.22 7.912c.436.013.821-.15 1.13-.458l.097-.097v1.668c0 .422.16.793.458 1.09.298.299.67.46 1.092.46.422 0 .793-.161 1.091-.46.298-.297.459-.668.459-1.09V7.357l.071.072c.309.308.693.471 1.13.471.435 0 .82-.163 1.128-.471.301-.303.47-.676.483-1.102v-.001a1.501 1.501 0 0 0-.46-1.13l-2.799-2.8a1.6 1.6 0 0 0-.528-.36l-.003-.001a1.54 1.54 0 0 0-1.145 0l-.002.001a1.6 1.6 0 0 0-.529.36L8.118 5.172l-.001.001a1.56 1.56 0 0 0-.47 1.128c0 .435.163.82.47 1.128.303.303.676.472 1.103.484Zm0 0 .014-.5-.015.5Zm-1.311 6.865a1.497 1.497 0 0 0-.458-1.13l-.097-.097h1.668c.421 0 .793-.16 1.09-.458.298-.299.46-.67.46-1.092a1.51 1.51 0 0 0-.46-1.091 1.507 1.507 0 0 0-1.09-.459H7.354l.071-.072c.309-.308.472-.692.472-1.128 0-.436-.163-.82-.472-1.13a1.596 1.596 0 0 0-1.101-.482h-.002a1.501 1.501 0 0 0-1.129.459l-2.8 2.8a1.6 1.6 0 0 0-.36.528l-.001.003a1.54 1.54 0 0 0 0 1.144v.003c.081.2.208.375.362.529l2.774 2.775c.309.308.693.47 1.129.47.435 0 .82-.162 1.128-.47.303-.302.472-.676.484-1.102Zm0 0-.5-.014.5.015v-.001Zm3.038-8.627-.5.5.5.207V6.15Zm.475 15.814.002.001a1.54 1.54 0 0 0 1.145 0h.002c.2-.081.375-.208.53-.362l2.775-2.774a1.56 1.56 0 0 0 .47-1.129c0-.435-.162-.82-.47-1.128a1.589 1.589 0 0 0-1.103-.484 1.497 1.497 0 0 0-1.13.458l-.096.097v-1.668c0-.421-.161-.793-.459-1.09a1.509 1.509 0 0 0-1.091-.46c-.422 0-.794.161-1.092.46a1.507 1.507 0 0 0-.458 1.09v1.668l-.072-.072a1.558 1.558 0 0 0-1.129-.471c-.436 0-.82.163-1.128.471l-.001.001c-.301.302-.47.675-.483 1.101v.001c-.012.437.151.821.46 1.13l2.8 2.8c.153.153.328.28.528.36Zm5.146-6.085c.303.302.676.47 1.102.484a1.5 1.5 0 0 0 1.13-.46l2.8-2.8a1.6 1.6 0 0 0 .36-.528l.002-.003a1.54 1.54 0 0 0 0-1.144l-.001-.003a1.6 1.6 0 0 0-.361-.529l-2.774-2.775h-.001a1.56 1.56 0 0 0-1.128-.47c-.436 0-.82.162-1.128.47h-.001a1.59 1.59 0 0 0-.484 1.102c-.012.437.15.822.459 1.13l.097.097h-1.668c-.422 0-.793.16-1.09.459-.299.298-.46.67-.46 1.091 0 .422.161.793.46 1.092.297.297.668.458 1.09.458h1.668l-.072.072a1.558 1.558 0 0 0-.472 1.129c0 .435.164.82.472 1.128ZM6.147 13.05l.5.5.207-.5h-.707Z\"/></svg>') 12 12,auto}.unreal-container.dolly{cursor:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" fill=\"none\"><mask id=\"a\" width=\"24\" height=\"24\" x=\"0\" y=\"0\" maskUnits=\"userSpaceOnUse\" style=\"mask-type:alpha\"><path fill=\"%23127392\" d=\"M0 0h24v24H0z\"/></mask><g mask=\"url(%23a)\"><path fill=\"%23000\" d=\"M12.002 20.575a1.026 1.026 0 0 1-.75-.325l-2.55-2.55a.933.933 0 0 1-.287-.713c.008-.275.113-.513.313-.713.2-.2.441-.3.725-.3.283 0 .524.1.724.3l.776.776V6.95l-.8.8c-.2.2-.438.296-.712.288a1.018 1.018 0 0 1-.713-.312c-.2-.2-.3-.442-.3-.726s.1-.525.3-.725l2.524-2.525a1.026 1.026 0 0 1 1.5 0l2.55 2.55c.2.2.296.438.289.713a1.023 1.023 0 0 1-.314.713c-.2.2-.441.3-.724.3a.988.988 0 0 1-.725-.3l-.775-.776v10.1l.8-.8c.2-.2.437-.296.712-.288.275.008.512.112.712.312.2.2.3.442.3.726s-.1.525-.3.725l-2.524 2.525a1.026 1.026 0 0 1-.75.325Z\"/><path stroke=\"%23fff\" stroke-opacity=\".7\" d=\"M13.553 8.153c.28.245.62.373 1 .373.415 0 .784-.153 1.078-.447.287-.287.446-.644.46-1.05v-.002a1.432 1.432 0 0 0-.435-1.08l-2.55-2.55-.354.353.354-.354a1.526 1.526 0 0 0-2.207 0L8.374 5.921A1.487 1.487 0 0 0 7.928 7c0 .416.152.785.446 1.08.288.287.645.446 1.052.457l4.127-.384Zm0 0v7.693m0-7.693a1.667 1.667 0 0 1-.079-.074l-3.021.075v7.693a1.482 1.482 0 0 0-1-.373c-.416 0-.785.153-1.079.447a1.522 1.522 0 0 0-.46 1.05v.002c-.011.417.14.786.435 1.08l2.55 2.55a1.527 1.527 0 0 0 2.207 0l2.525-2.524c.294-.294.446-.663.446-1.079 0-.416-.152-.785-.446-1.08a1.518 1.518 0 0 0-1.052-.457m0 0-.014.5.014-.5Zm0 0a1.424 1.424 0 0 0-1.027.383m0 0a1.583 1.583 0 0 0-.053.05l.354.354m-.3-.404v.104l.3.3m0 0-.3-.3v.6l.3-.3Z\"/></g></svg>%0A') 12 12,auto}.unreal-container.disabled:before{content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}#playerUI{position:absolute;z-index:10;width:100%}#statsContainer{display:none;text-align:left;background-color:#000c}#stats{padding:6px;font-weight:700;font-size:14px;color:#0f0}canvas{position:absolute;image-rendering:crisp-edges}#overlay{position:absolute;top:0;right:2%;z-index:100;padding:4px;border-top-width:0;-webkit-border-bottom-right-radius:5px;-moz-border-radius-bottomright:5px;border-bottom-right-radius:5px;-webkit-border-bottom-left-radius:5px;-moz-border-radius-bottomleft:5px;border-bottom-left-radius:5px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none}.overlay{font-weight:lighter;font-family:var(--src-font-family-body)}#overlayButton:hover{cursor:pointer}#overlayButton{font-size:40px;text-align:right}#overlaySettings{display:none;width:300px;padding:4px}#videoMessageOverlay{position:absolute;z-index:20;width:100%;margin:auto;font-size:1.8em;font-family:var(--src-font-family-body)}#playButton{display:inline-block;height:auto}img#playButton{width:10%;max-width:241px}#UIInteraction{position:fixed}#UIInteractionButtonBoundary{padding:2px}#UIInteractionButton{cursor:pointer}#hiddenInput{position:absolute;left:-10%;width:0;opacity:0}#editTextButton{position:absolute;width:40px;height:40px}\n"] }]
5965
+ }], ctorParameters: () => [], propDecorators: { isStudio: [{ type: i0.Input, args: [{ isSignal: true, alias: "isStudio", required: false }] }], useContainerAsSizeProvider: [{ type: i0.Input, args: [{ isSignal: true, alias: "useContainerAsSizeProvider", required: false }] }], studioResolutionSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "studioResolutionSize", required: false }] }], videoElement: [{ type: i0.ViewChild, args: ['videoContainer', { isSignal: true }] }], changeMouseOverScene: [{ type: i0.Output, args: ["changeMouseOverScene"] }], onMouseOver: [{
5966
+ type: HostListener,
5967
+ args: ['mouseover']
5968
+ }], onMouseOut: [{
5969
+ type: HostListener,
5970
+ args: ['mouseout']
5971
+ }] } });
5972
+
5973
+ class WebrtcErrorModalComponent {
5974
+ #dialogRef = inject(DialogRef);
5975
+ #store = inject(Store);
5976
+ close() {
5977
+ this.#dialogRef.close();
5978
+ }
5979
+ closeModalWithCirrusDisconnect() {
5980
+ this.#store
5981
+ .select(unrealFeature.selectCirrusConnected)
5982
+ .pipe(take(1))
5983
+ .subscribe((isConnected) => {
5984
+ if (!isConnected) {
5985
+ this.#store.dispatch(disconnectStream({
5986
+ reason: DisconnectReason.DropConnection,
5987
+ message: 'Modal Drop Connection',
5988
+ }));
6048
5989
  }
5990
+ this.close();
5991
+ });
5992
+ }
5993
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebrtcErrorModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5994
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: WebrtcErrorModalComponent, isStandalone: true, selector: "app-webrtc-error-modal", ngImport: i0, template: "<div [attr.data-testid]=\"'webrtc-error-modal'\" class=\"src-modal\">\n <header class=\"src-modal__header\"></header>\n <section class=\"src-modal__body\">\n <div [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n WebRTC error\n </div>\n <div style=\"text-align: left\">\n An internet connection type (WebRTC) appears to be blocked either by your\n browser settings or your current network. If WebRTC is blocked on your\n browser you may be able to adjust these settings yourself based on\n instructions here:\n <a href=\"https://myownconference.com/blog/en/webrtc/\" target=\"blank\"\n >https://myownconference.com/blog/en/webrtc/</a\n >\n <br /><br />\n Trying a different web browser may help confirm this as well. If WebRTC is\n blocked by your network, try switching to a different network if possible\n or contact your network administrator.<br /><br />\n WebRTC is common, safe and increasingly utilised method for streaming real\n time 3D experiences via a web browser. It typically consumes no more\n bandwidth than streaming an HD video.\n </div>\n </section>\n <footer class=\"src-modal__footer\">\n <src-button\n (onClick)=\"closeModalWithCirrusDisconnect()\"\n [data-testid]=\"'close-webrtc-error-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </footer>\n</div>\n", styles: [".src-modal{--modalBodyPadding: 20px 8px 20px 20px;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto}.src-modal ::ng-deep .ng-scroll-content{--_scrollbar-content-width: initial;--_viewport-padding-right: 12px}.src-modal__body{width:100%}.src-modal__body p{color:var(--src-text-body-main, #1f2937);font-size:14px;font-style:normal;font-weight:400;line-height:24px;margin-top:0}.src-modal__body a{word-wrap:break-word;white-space:normal}\n"], dependencies: [{ kind: "component", type: SourceButtonComponent, selector: "src-button", inputs: ["type", "appearance", "weight", "size", "context", "customClass", "isFullWidth", "isPressed", "isDisabled", "isLoading", "isInverted", "iconButton", "srcButtonConfig", "formID", "data-testid", "ariaLabel"], outputs: ["onClick", "onSubmit"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
5995
+ }
5996
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebrtcErrorModalComponent, decorators: [{
5997
+ type: Component,
5998
+ args: [{ selector: 'app-webrtc-error-modal', changeDetection: ChangeDetectionStrategy.OnPush, imports: [SourceButtonComponent], template: "<div [attr.data-testid]=\"'webrtc-error-modal'\" class=\"src-modal\">\n <header class=\"src-modal__header\"></header>\n <section class=\"src-modal__body\">\n <div [attr.data-testid]=\"'title'\" class=\"src-modal__title\">\n WebRTC error\n </div>\n <div style=\"text-align: left\">\n An internet connection type (WebRTC) appears to be blocked either by your\n browser settings or your current network. If WebRTC is blocked on your\n browser you may be able to adjust these settings yourself based on\n instructions here:\n <a href=\"https://myownconference.com/blog/en/webrtc/\" target=\"blank\"\n >https://myownconference.com/blog/en/webrtc/</a\n >\n <br /><br />\n Trying a different web browser may help confirm this as well. If WebRTC is\n blocked by your network, try switching to a different network if possible\n or contact your network administrator.<br /><br />\n WebRTC is common, safe and increasingly utilised method for streaming real\n time 3D experiences via a web browser. It typically consumes no more\n bandwidth than streaming an HD video.\n </div>\n </section>\n <footer class=\"src-modal__footer\">\n <src-button\n (onClick)=\"closeModalWithCirrusDisconnect()\"\n [data-testid]=\"'close-webrtc-error-modal'\"\n weight=\"primary\"\n >\n Ok\n </src-button>\n </footer>\n</div>\n", styles: [".src-modal{--modalBodyPadding: 20px 8px 20px 20px;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto}.src-modal ::ng-deep .ng-scroll-content{--_scrollbar-content-width: initial;--_viewport-padding-right: 12px}.src-modal__body{width:100%}.src-modal__body p{color:var(--src-text-body-main, #1f2937);font-size:14px;font-style:normal;font-weight:400;line-height:24px;margin-top:0}.src-modal__body a{word-wrap:break-word;white-space:normal}\n"] }]
5999
+ }] });
6000
+
6001
+ class FilterSettingsComponent {
6002
+ constructor() {
6003
+ this.store = inject(Store);
6004
+ this.storageKey = 'LBMFilterSettings';
6005
+ this.defaultFilterModel = { ...DefaultFilterModel };
6006
+ this.settings = { ...DefaultFilterModel };
6007
+ }
6008
+ ngOnInit() {
6009
+ const savedSettings = localStorage.getItem(this.storageKey);
6010
+ if (savedSettings) {
6011
+ this.dispatch(JSON.parse(savedSettings));
6049
6012
  }
6050
6013
  }
6051
- setupDataChannelCallbacks(datachannel) {
6014
+ togglePanel() {
6015
+ this.settings.panelOpen = !this.settings.panelOpen;
6016
+ this.saveSettings();
6017
+ }
6018
+ saveSettings() {
6019
+ const valid = Object.entries(this.settings).reduce((acc, [key, value]) => {
6020
+ return Truthy(value) ? { ...acc, [key]: value } : acc;
6021
+ }, {});
6022
+ this.dispatch({ ...DefaultFilterModel, ...valid });
6023
+ }
6024
+ dispatch(settings) {
6052
6025
  try {
6053
- Logger.log(`[DATACHANNEL] Setting up datachannel "${datachannel.label}" callbacks...`);
6054
- this.commandTelemetryService.trackStartCommand('EXT-DataChannel Connection');
6055
- // Inform browser we would like binary data as an ArrayBuffer (FF chooses Blob by default!)
6056
- datachannel.binaryType = 'arraybuffer';
6057
- fromEvent(datachannel, 'close')
6058
- .pipe(
6059
- // Only dispatch disconnect if not already reconnecting to avoid infinite loops
6060
- filter(() => !this.isReconnecting()), take(1))
6061
- .subscribe((e) => {
6062
- Logger.warn(`[DATACHANNEL] Data channel disconnected: ${datachannel.label}(${datachannel.id})`, e);
6063
- this.store.dispatch(disconnectStream({
6064
- reason: DisconnectReason.DataChannelClosed,
6065
- message: 'DataChannelClosed',
6066
- }));
6067
- });
6068
- fromEvent(datachannel, 'error')
6069
- .pipe(takeUntil(this.disconnect$))
6070
- .subscribe((e) => {
6071
- const message = `[DATACHANNEL] Data channel "${datachannel.label}" error. id:${datachannel.id}`;
6072
- Logger.error(message, e);
6073
- });
6074
- fromEvent(datachannel, 'open')
6075
- .pipe(tap$1(() => {
6076
- Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
6077
- }), takeUntil(this.disconnect$))
6078
- .subscribe(() => {
6079
- this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
6080
- this.onDataChannelConnected();
6081
- });
6082
- fromEvent(datachannel, 'message')
6083
- .pipe(
6084
- /* tap((e: MessageEvent) => {
6085
- const view = new Uint8Array(e.data);
6086
- Logger.info(
6087
- `[DATACHANNEL] Message received on "${datachannel.label}" id=${datachannel.id}, byteCode=${view[0]}, size=${view.byteLength}`,
6088
- );
6089
- }),*/
6090
- map$1((e) => e.data), takeUntil(this.disconnect$))
6091
- .subscribe((data) => this.onDataChannelMessage$.next(data));
6092
- return datachannel;
6026
+ localStorage.setItem(this.storageKey, JSON.stringify(settings));
6093
6027
  }
6094
6028
  catch (e) {
6095
- Logger.warn('[DATACHANNEL] Datachannel setup caused an exception: ', e);
6096
- return null;
6029
+ Logger.error('Storage not available, Error saving filter settings:', e);
6097
6030
  }
6031
+ Object.assign(this.settings, settings);
6032
+ Object.assign(FilterModel, settings);
6033
+ this.store.dispatch(setKalmanSettings({ settings }));
6098
6034
  }
6099
- onDataChannelConnected() {
6100
- let statusMessage = 'Session connected, video stream starting.';
6101
- if (navigator.userAgent.includes('Firefox')) {
6102
- statusMessage +=
6103
- '\n If you are experiencing connection problems please try Google Chrome';
6104
- }
6105
- this.store.dispatch(dataChannelConnected({ statusMessage }));
6035
+ reset() {
6036
+ this.dispatch({
6037
+ ...DefaultFilterModel,
6038
+ panelOpen: this.settings.panelOpen,
6039
+ });
6106
6040
  }
6107
- /**
6108
- * Creates an SDP offer, applies SDP munging, sets it as local description,
6109
- * and sends it to the server via signaling.
6110
- * Transceivers already configure media directions, so deprecated
6111
- * offerToReceive* options are not used.
6112
- */
6113
- async createOffer(pcClient) {
6114
- try {
6115
- const offer = await pcClient.createOffer();
6116
- offer.sdp = this.mungeSDP(offer.sdp ?? '');
6117
- await pcClient.setLocalDescription(offer);
6118
- this.signallingSrv.send(offer);
6119
- }
6120
- catch {
6121
- Logger.error("Couldn't create offer");
6122
- }
6041
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FilterSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
6042
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: FilterSettingsComponent, isStandalone: true, selector: "app-filter-settings", ngImport: i0, template: "<div class=\"settings-container\">\n <button (click)=\"togglePanel()\" class=\"gear-button\">LBM</button>\n @if (settings.panelOpen) {\n <div class=\"settings-panel\">\n <h3>LBM Trigger Settings</h3>\n <h5>Data Flow Monitor</h5>\n <form>\n <label>\n Monitoring delay:\n <input\n [(ngModel)]=\"settings.monitoringDelayTime\"\n [placeholder]=\"defaultFilterModel.monitoringDelayTime\"\n type=\"number\"\n name=\"monitoringDelayTime\"\n />\n </label>\n\n <label>\n Minimum Bitrate (kbit/s):\n <input\n [(ngModel)]=\"settings.minimumBitrate\"\n [placeholder]=\"defaultFilterModel.minimumBitrate\"\n type=\"number\"\n name=\"minimumBitrate\"\n />\n </label>\n <label>\n Yellow Flag (%):\n <input\n [(ngModel)]=\"settings.yellowFlag\"\n [placeholder]=\"defaultFilterModel.yellowFlag\"\n type=\"number\"\n name=\"yellowFlag\"\n />\n </label>\n <label>\n Red Flag (%):\n <input\n [(ngModel)]=\"settings.redFlag\"\n [placeholder]=\"defaultFilterModel.redFlag\"\n type=\"number\"\n name=\"redFlag\"\n />\n </label>\n <label>\n Minimum FPS:\n <input\n [(ngModel)]=\"settings.minimumFps\"\n [placeholder]=\"defaultFilterModel.minimumFps\"\n type=\"number\"\n name=\"minimumFps\"\n />\n </label>\n <hr />\n <h5>Kalman Filter</h5>\n <label>\n Initial Bitrate Estimate (kbit/s):\n <input\n [(ngModel)]=\"settings.initialBitrateEstimate\"\n [placeholder]=\"defaultFilterModel.initialBitrateEstimate\"\n type=\"number\"\n name=\"initialBitrateEstimate\"\n />\n </label>\n <label>\n Initial Error Covariance:\n <input\n [(ngModel)]=\"settings.initialErrorCovariance\"\n [placeholder]=\"defaultFilterModel.initialErrorCovariance\"\n type=\"number\"\n name=\"initialErrorCovariance\"\n />\n </label>\n <label>\n Process Noise (Q):\n <input\n [(ngModel)]=\"settings.processNoise\"\n [placeholder]=\"defaultFilterModel.processNoise\"\n type=\"number\"\n name=\"processNoise\"\n />\n </label>\n <label>\n Measurement Noise (R):\n <input\n [(ngModel)]=\"settings.measurementNoise\"\n [placeholder]=\"defaultFilterModel.measurementNoise\"\n type=\"number\"\n name=\"measurementNoise\"\n />\n </label>\n </form>\n <br />\n <br />\n <div class=\"apply-button-container\">\n <button (click)=\"reset()\">Reset</button>\n <button (click)=\"saveSettings()\">Apply</button>\n </div>\n </div>\n }\n</div>\n", styles: [".settings-container{position:absolute;top:65px;right:10px;z-index:1000}.gear-button{right:0;width:50px;position:absolute;cursor:pointer;font-size:10px;background:none;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#e6f419;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}.settings-panel{font-size:13px;background:#fff;border:1px solid #ccc;padding:15px;margin-top:25px;border-radius:4px;box-shadow:0 2px 8px #0003}.settings-panel h3{margin-top:0}.settings-panel form{display:flex;flex-direction:column}.settings-panel label{margin-bottom:10px;display:flex;flex-direction:column}.settings-panel input{padding:4px;margin-top:4px;border:1px solid #ccc;border-radius:2px}.apply-button-container{position:absolute;bottom:15px;right:15px}.apply-button-container button{padding:6px 12px;border:none;background-color:#1976d2;color:#fff;border-radius:4px;cursor:pointer;margin:2px}.apply-button-container button:hover{background-color:#1565c0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
6043
+ }
6044
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: FilterSettingsComponent, decorators: [{
6045
+ type: Component,
6046
+ args: [{ selector: 'app-filter-settings', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"settings-container\">\n <button (click)=\"togglePanel()\" class=\"gear-button\">LBM</button>\n @if (settings.panelOpen) {\n <div class=\"settings-panel\">\n <h3>LBM Trigger Settings</h3>\n <h5>Data Flow Monitor</h5>\n <form>\n <label>\n Monitoring delay:\n <input\n [(ngModel)]=\"settings.monitoringDelayTime\"\n [placeholder]=\"defaultFilterModel.monitoringDelayTime\"\n type=\"number\"\n name=\"monitoringDelayTime\"\n />\n </label>\n\n <label>\n Minimum Bitrate (kbit/s):\n <input\n [(ngModel)]=\"settings.minimumBitrate\"\n [placeholder]=\"defaultFilterModel.minimumBitrate\"\n type=\"number\"\n name=\"minimumBitrate\"\n />\n </label>\n <label>\n Yellow Flag (%):\n <input\n [(ngModel)]=\"settings.yellowFlag\"\n [placeholder]=\"defaultFilterModel.yellowFlag\"\n type=\"number\"\n name=\"yellowFlag\"\n />\n </label>\n <label>\n Red Flag (%):\n <input\n [(ngModel)]=\"settings.redFlag\"\n [placeholder]=\"defaultFilterModel.redFlag\"\n type=\"number\"\n name=\"redFlag\"\n />\n </label>\n <label>\n Minimum FPS:\n <input\n [(ngModel)]=\"settings.minimumFps\"\n [placeholder]=\"defaultFilterModel.minimumFps\"\n type=\"number\"\n name=\"minimumFps\"\n />\n </label>\n <hr />\n <h5>Kalman Filter</h5>\n <label>\n Initial Bitrate Estimate (kbit/s):\n <input\n [(ngModel)]=\"settings.initialBitrateEstimate\"\n [placeholder]=\"defaultFilterModel.initialBitrateEstimate\"\n type=\"number\"\n name=\"initialBitrateEstimate\"\n />\n </label>\n <label>\n Initial Error Covariance:\n <input\n [(ngModel)]=\"settings.initialErrorCovariance\"\n [placeholder]=\"defaultFilterModel.initialErrorCovariance\"\n type=\"number\"\n name=\"initialErrorCovariance\"\n />\n </label>\n <label>\n Process Noise (Q):\n <input\n [(ngModel)]=\"settings.processNoise\"\n [placeholder]=\"defaultFilterModel.processNoise\"\n type=\"number\"\n name=\"processNoise\"\n />\n </label>\n <label>\n Measurement Noise (R):\n <input\n [(ngModel)]=\"settings.measurementNoise\"\n [placeholder]=\"defaultFilterModel.measurementNoise\"\n type=\"number\"\n name=\"measurementNoise\"\n />\n </label>\n </form>\n <br />\n <br />\n <div class=\"apply-button-container\">\n <button (click)=\"reset()\">Reset</button>\n <button (click)=\"saveSettings()\">Apply</button>\n </div>\n </div>\n }\n</div>\n", styles: [".settings-container{position:absolute;top:65px;right:10px;z-index:1000}.gear-button{right:0;width:50px;position:absolute;cursor:pointer;font-size:10px;background:none;font-weight:500;border:1px solid;border-radius:9px;background:#0162cc;color:#fff;padding:4px}.gear-button>*{color:#e6f419;filter:drop-shadow(1px 1px 1px rgb(0,0,0))}.settings-panel{font-size:13px;background:#fff;border:1px solid #ccc;padding:15px;margin-top:25px;border-radius:4px;box-shadow:0 2px 8px #0003}.settings-panel h3{margin-top:0}.settings-panel form{display:flex;flex-direction:column}.settings-panel label{margin-bottom:10px;display:flex;flex-direction:column}.settings-panel input{padding:4px;margin-top:4px;border:1px solid #ccc;border-radius:2px}.apply-button-container{position:absolute;bottom:15px;right:15px}.apply-button-container button{padding:6px 12px;border:none;background-color:#1976d2;color:#fff;border-radius:4px;cursor:pointer;margin:2px}.apply-button-container button:hover{background-color:#1565c0}\n"] }]
6047
+ }] });
6048
+
6049
+ class LowBandwidthDetectorComponent {
6050
+ constructor() {
6051
+ this.store = inject(Store);
6052
+ this.dialog = inject(Dialog);
6053
+ this.destroyRef = inject(DestroyRef);
6054
+ this.videoService = inject(VideoService);
6055
+ this.isDevMode = inject(DevModeService).isDevMode;
6056
+ this.viewportReady$ = this.store.select(unrealFeature.selectViewportReady);
6057
+ this.isLowBandwidth$ = this.store.select(unrealFeature.selectLowBandwidth);
6058
+ this.lowBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Truthy));
6059
+ this.highBandwidthTrigger$ = this.isLowBandwidth$.pipe(distinctUntilChanged$1(), filter(Falsy));
6060
+ this.viewPortDestroyedTrigger$ = this.viewportReady$.pipe(distinctUntilChanged$1(), filter(Falsy));
6061
+ this.aggregatedStats$ = this.videoService.videoStats$.pipe(map((data) => data.aggregatedStats), share());
6062
+ this.nativeQuality$ = this.videoService.videoStats$.pipe(map((data) => data.quality));
6063
+ this.monitoringTrigger$ = this.viewportReady$
6064
+ .pipe(combineLatestWith(this.isLowBandwidth$), map(([viewPortReady, isLowBandwidth]) => viewPortReady && !isLowBandwidth))
6065
+ .pipe(share());
6066
+ this.reTrigger$ = this.monitoringTrigger$.pipe(map(() => ({ quality: 'lime' })));
6067
+ this.canSwitchToLowBand$ = this.monitoringTrigger$.pipe(debounceTime(100), switchMap((canSwitch) => concat(of(false), timer(FilterModel.monitoringDelayTime).pipe(map(() => canSwitch))).pipe(take(2), takeUntil$1(this.monitoringTrigger$))), distinctUntilChanged$1(), share());
6068
+ this.videoQuality$ = merge(this.nativeQuality$, this.reTrigger$).pipe(share(), takeUntil$1(this.viewPortDestroyedTrigger$));
6069
+ this.fps$ = this.aggregatedStats$.pipe(map((data) => data.framesPerSecond || 0));
6070
+ this.bitrateDrop$ = this.aggregatedStats$.pipe(map((data) => data.dataFlowCheckResult), filter((br) => br.isDropDetected), takeUntil$1(merge(this.viewPortDestroyedTrigger$, this.reTrigger$)));
6071
+ this.fps = toSignal(this.fps$, { initialValue: 0 });
6072
+ this.nativeQuality = toSignal(this.nativeQuality$);
6073
+ this.isLowBandwidth = toSignal(this.isLowBandwidth$);
6074
+ this.aggregatedStats = toSignal(this.aggregatedStats$);
6075
+ this.isReducedQuality = signal(false, ...(ngDevMode ? [{ debugName: "isReducedQuality" }] : []));
6076
+ this.isIndicatorExpanded = signal(false, ...(ngDevMode ? [{ debugName: "isIndicatorExpanded" }] : []));
6077
+ this.isIndicatorExpandedTrigger$ = toObservable(this.isIndicatorExpanded).pipe(skip(1), distinctUntilChanged$1(), filter(Falsy));
6123
6078
  }
6124
- /**
6125
- * Creates an SDP answer, applies SDP munging, sets it as local description,
6126
- * and sends it to the server via signaling.
6127
- */
6128
- async createAnswer(pcClient) {
6129
- try {
6130
- const answer = await pcClient.createAnswer();
6131
- answer.sdp = this.mungeSDP(answer.sdp ?? '');
6132
- await pcClient.setLocalDescription(answer);
6133
- this.signallingSrv.send(answer);
6079
+ ngOnInit() {
6080
+ this.lowBandwidthTrigger$
6081
+ .pipe(takeUntilDestroyed(this.destroyRef))
6082
+ .subscribe(() => this.toggleIndicator(true));
6083
+ merge(this.viewPortDestroyedTrigger$, this.highBandwidthTrigger$)
6084
+ .pipe(takeUntilDestroyed(this.destroyRef))
6085
+ .subscribe(() => this.isReducedQuality.set(false));
6086
+ this.canSwitchToLowBand$
6087
+ .pipe(filter(Truthy), tap(() => {
6088
+ BITRATE_MONITOR.reset();
6089
+ this.isReducedQuality.set(false);
6090
+ }), switchMap(() => this.bitrateDrop$), tap(this.trySetLowBandwidth.bind(this)), map((dataFlowResult) => dataFlowResult?.isDropDetected), distinctUntilChanged$1(), tap(() => {
6091
+ this.isReducedQuality.set(true);
6092
+ }), switchMap(() => this.videoQuality$), filter((quality) => quality === 'lime'), tap(() => this.isReducedQuality.set(false)), takeUntilDestroyed(this.destroyRef))
6093
+ .subscribe();
6094
+ }
6095
+ trySetLowBandwidth(dataFlowResult) {
6096
+ // Evaluate FPS and native quality
6097
+ const isFpsLow = this.fps() < FilterModel.minimumFps;
6098
+ const isNativeQualityRed = this.nativeQuality() === 'red';
6099
+ const shouldSwitch = isNativeQualityRed || isFpsLow;
6100
+ // Exit if conditions are not met or already in low bandwidth
6101
+ if (!shouldSwitch || this.isLowBandwidth())
6102
+ return;
6103
+ const { quality, activeMedian } = dataFlowResult;
6104
+ const isQualityRed = quality === 'red';
6105
+ const isLowBitrate = activeMedian < FilterModel.minimumBitrate;
6106
+ // Final check for a red drop or low bitrate, or low FPS
6107
+ if (isQualityRed || isLowBitrate || isFpsLow) {
6108
+ let reason;
6109
+ if (isFpsLow) {
6110
+ reason = `FPS < ${FilterModel.minimumFps}`;
6111
+ }
6112
+ else if (quality === 'red') {
6113
+ reason = `Drop > ${FilterModel.redFlag}%`;
6114
+ }
6115
+ else {
6116
+ reason = `Speed < ${FilterModel.minimumBitrate}`;
6117
+ }
6118
+ Logger.info(`LBM DETECTED: ${reason}, switching to low bandwidth; `, dataFlowResult.message);
6119
+ this.store.dispatch(changeLowBandwidth({
6120
+ lowBandwidth: true,
6121
+ stats: {
6122
+ dataFlowResult,
6123
+ reason,
6124
+ triggerTime: this.aggregatedStats()?.runTime ?? 0,
6125
+ },
6126
+ }));
6134
6127
  }
6135
- catch (e) {
6136
- Logger.error("Couldn't create answer", e);
6128
+ }
6129
+ toggleIndicator(value) {
6130
+ this.isIndicatorExpanded.set(value);
6131
+ if (value) {
6132
+ this.hideIndicatorAfterDelay();
6137
6133
  }
6138
6134
  }
6139
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
6140
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2 }); }
6135
+ hideIndicatorAfterDelay() {
6136
+ timer(3000)
6137
+ .pipe(take(1), takeUntil$1(this.isIndicatorExpandedTrigger$))
6138
+ .subscribe(() => this.toggleIndicator(false));
6139
+ }
6140
+ openLBMDialog() {
6141
+ this.dialog
6142
+ .open(LowBandwidthModalComponent)
6143
+ .closed.pipe(filter(Truthy))
6144
+ .subscribe(() => {
6145
+ this.toggleIndicator(false);
6146
+ this.store.dispatch(changeLowBandwidth({ lowBandwidth: false }));
6147
+ });
6148
+ }
6149
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthDetectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
6150
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: LowBandwidthDetectorComponent, isStandalone: true, selector: "app-low-bandwidth-detector", ngImport: i0, template: "@if (isReducedQuality() || isLowBandwidth()) {\n <div\n [class.expanded]=\"isIndicatorExpanded() && isLowBandwidth()\"\n class=\"lbm-indicator freeze-loader\"\n >\n <div (click)=\"toggleIndicator(true)\" class=\"lbm-icon\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 20 20\">\n <path\n fill=\"#85888E\"\n d=\"M7.189 3.605c-.73.145-1.438.35-2.126.614A14.412 14.412 0 0 0 .896 6.666a1.08 1.08 0 0 0-.375.844c0 .34.118.629.354.865s.524.36.865.375c.34.014.65-.09.927-.313a11.89 11.89 0 0 1 3.385-1.916 10.94 10.94 0 0 1 1.235-.375l-.098-2.541ZM7.385 8.708a9.107 9.107 0 0 0-2.906 1.48c-.264.194-.402.464-.416.812-.014.347.104.646.354.896.236.236.524.364.864.385.34.02.664-.073.97-.281.422-.29.878-.53 1.368-.721a2.545 2.545 0 0 1-.166-.814l-.068-1.757ZM12.386 11.267c.094-.25.15-.52.161-.802l.068-1.755a9.019 9.019 0 0 1 2.927 1.52c.264.193.4.46.406.801.007.34-.114.635-.364.885a1.213 1.213 0 0 1-.865.365A1.614 1.614 0 0 1 13.77 12a6.574 6.574 0 0 0-1.385-.733ZM12.713 6.146l.098-2.542c.73.146 1.438.351 2.127.615 1.541.59 2.93 1.406 4.166 2.447.25.223.379.5.386.834.007.333-.115.625-.365.875a1.253 1.253 0 0 1-.864.375c-.34.014-.65-.09-.927-.313a11.892 11.892 0 0 0-3.386-1.916 10.94 10.94 0 0 0-1.235-.375ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n />\n <path\n fill=\"#fff\"\n fill-rule=\"evenodd\"\n d=\"M10 .833c-.91 0-1.637.756-1.602 1.665l.304 7.92a1.299 1.299 0 0 0 2.596 0l.305-7.92A1.604 1.604 0 0 0 10 .833ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </div>\n\n <div\n [class.lbm-message--open]=\"isLowBandwidth() && isIndicatorExpanded()\"\n class=\"lbm-message\"\n >\n <p class=\"lbm-description\">\n Fluid Interactivity Modes were disabled due to an unstable connection.\n\n <button\n (click)=\"openLBMDialog()\"\n [attr.data-testid]=\"'learn-more-lbm'\"\n type=\"button\"\n class=\"lbm-learn-more\"\n >\n Learn more\n </button>\n </p>\n <button\n (click)=\"toggleIndicator(false)\"\n [attr.data-testid]=\"'close-lbm-indicator'\"\n type=\"button\"\n class=\"lbm-close\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n >\n <path\n d=\"M10.0001 11.2289L6.68618 14.5428C6.51951 14.7095 6.31818 14.7895 6.08218 14.7828C5.84618 14.7755 5.64485 14.6885 5.47818 14.5218C5.31151 14.3552 5.22818 14.1505 5.22818 13.9078C5.22818 13.6645 5.31151 13.4595 5.47818 13.2928L8.77107 9.99992L5.45718 6.68603C5.29051 6.51936 5.21051 6.3147 5.21718 6.07203C5.22451 5.8287 5.31151 5.6237 5.47818 5.45703C5.64485 5.29036 5.84951 5.20703 6.09218 5.20703C6.33551 5.20703 6.54051 5.29036 6.70718 5.45703L10.0001 8.77092L13.314 5.45703C13.4806 5.29036 13.6853 5.20703 13.928 5.20703C14.1713 5.20703 14.3763 5.29036 14.543 5.45703C14.7096 5.6237 14.793 5.8287 14.793 6.07203C14.793 6.3147 14.7096 6.51936 14.543 6.68603L11.2291 9.99992L14.543 13.3138C14.7096 13.4805 14.793 13.6818 14.793 13.9178C14.793 14.1538 14.7096 14.3552 14.543 14.5218C14.3763 14.6885 14.1713 14.7718 13.928 14.7718C13.6853 14.7718 13.4806 14.6885 13.314 14.5218L10.0001 11.2289Z\"\n fill=\"white\"\n />\n </svg>\n </button>\n </div>\n </div>\n}\n\n@if (isDevMode) {\n <app-filter-settings />\n}\n", styles: [".freeze-loader,.lbm-indicator{--lbmPositionTop: 18px;--lbmPositionLeft: 18px;--lbmPositionRight: 18px;--lbmIndicatorSize: 36px;--lbmIndicatorBorderRadius: 8px;--lbmIndicatorCloseSize: 20px;--lbmPadding: 10px;--lbmTextSize: 13px;--lbmLineHeight: 20px;--lbmTextColor: #ffffff;--lbmBackgroundColor: rgba(17, 24, 39, .7);position:absolute;top:var(--lbmPositionTop);left:var(--lbmPositionLeft);z-index:10;display:flex;justify-content:center;align-items:flex-start;min-width:var(--lbmIndicatorSize);min-height:var(--lbmIndicatorSize);overflow:hidden}.freeze-loader.expanded,.lbm-indicator.expanded{--lbmPositionTop: 8px;--lbmPositionLeft: 8px;--lbmPositionRight: 8px;width:auto;max-width:calc(100% - var(--lbmPositionLeft) - var(--lbmPositionRight));padding:var(--lbmPadding);background:var(--lbmBackgroundColor);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:var(--lbmIndicatorBorderRadius);gap:12px}.freeze-loader .lbm-icon,.lbm-indicator .lbm-icon{display:flex;width:var(--lbmIndicatorSize);height:var(--lbmIndicatorSize);padding:8px;color:#fff;background-color:var(--lbmBackgroundColor);border-radius:50%;flex-shrink:0;cursor:pointer}.freeze-loader .lbm-icon svg,.freeze-loader .lbm-icon img,.lbm-indicator .lbm-icon svg,.lbm-indicator .lbm-icon img{width:100%;height:100%}.freeze-loader .lbm-close,.lbm-indicator .lbm-close{position:absolute;top:0;right:0;width:var(--lbmIndicatorCloseSize);height:var(--lbmIndicatorCloseSize);background-color:transparent;border:none;padding:0;cursor:pointer}.freeze-loader .lbm-message,.lbm-indicator .lbm-message{position:relative;opacity:0;height:0;max-width:0;padding-right:24px;pointer-events:none;visibility:hidden;animation:closeMessage .3s forwards}.freeze-loader .lbm-message--open,.lbm-indicator .lbm-message--open{position:relative;visibility:visible;display:flex;align-items:center;align-self:center;pointer-events:all;gap:12px;max-width:fit-content;height:auto;opacity:1;animation:openMessage .3s forwards}.freeze-loader .lbm-description,.lbm-indicator .lbm-description{margin:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor)}.freeze-loader .lbm-learn-more,.lbm-indicator .lbm-learn-more{display:inline-flex;padding:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor);-webkit-text-decoration-line:underline;text-decoration-line:underline;background-color:transparent;border:none;cursor:pointer}.freeze-loader .lbm-learn-more:hover,.lbm-indicator .lbm-learn-more:hover{text-decoration:none}@keyframes openMessage{0%{opacity:0;max-width:0;height:0}10%{height:auto;max-width:fit-content}to{opacity:1;height:auto;max-width:fit-content}}@keyframes closeMessage{0%{opacity:1;max-width:fit-content}to{opacity:0;max-width:0}}\n"], dependencies: [{ kind: "component", type: FilterSettingsComponent, selector: "app-filter-settings" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
6141
6151
  }
6142
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2, decorators: [{
6143
- type: Injectable
6144
- }], ctorParameters: () => [] });
6152
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: LowBandwidthDetectorComponent, decorators: [{
6153
+ type: Component,
6154
+ args: [{ selector: 'app-low-bandwidth-detector', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FilterSettingsComponent], template: "@if (isReducedQuality() || isLowBandwidth()) {\n <div\n [class.expanded]=\"isIndicatorExpanded() && isLowBandwidth()\"\n class=\"lbm-indicator freeze-loader\"\n >\n <div (click)=\"toggleIndicator(true)\" class=\"lbm-icon\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 20 20\">\n <path\n fill=\"#85888E\"\n d=\"M7.189 3.605c-.73.145-1.438.35-2.126.614A14.412 14.412 0 0 0 .896 6.666a1.08 1.08 0 0 0-.375.844c0 .34.118.629.354.865s.524.36.865.375c.34.014.65-.09.927-.313a11.89 11.89 0 0 1 3.385-1.916 10.94 10.94 0 0 1 1.235-.375l-.098-2.541ZM7.385 8.708a9.107 9.107 0 0 0-2.906 1.48c-.264.194-.402.464-.416.812-.014.347.104.646.354.896.236.236.524.364.864.385.34.02.664-.073.97-.281.422-.29.878-.53 1.368-.721a2.545 2.545 0 0 1-.166-.814l-.068-1.757ZM12.386 11.267c.094-.25.15-.52.161-.802l.068-1.755a9.019 9.019 0 0 1 2.927 1.52c.264.193.4.46.406.801.007.34-.114.635-.364.885a1.213 1.213 0 0 1-.865.365A1.614 1.614 0 0 1 13.77 12a6.574 6.574 0 0 0-1.385-.733ZM12.713 6.146l.098-2.542c.73.146 1.438.351 2.127.615 1.541.59 2.93 1.406 4.166 2.447.25.223.379.5.386.834.007.333-.115.625-.365.875a1.253 1.253 0 0 1-.864.375c-.34.014-.65-.09-.927-.313a11.892 11.892 0 0 0-3.386-1.916 10.94 10.94 0 0 0-1.235-.375ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n />\n <path\n fill=\"#fff\"\n fill-rule=\"evenodd\"\n d=\"M10 .833c-.91 0-1.637.756-1.602 1.665l.304 7.92a1.299 1.299 0 0 0 2.596 0l.305-7.92A1.604 1.604 0 0 0 10 .833ZM8.813 16.187c.32.32.715.48 1.187.48.472 0 .868-.16 1.188-.48.32-.32.479-.715.479-1.187 0-.473-.16-.868-.48-1.188-.319-.32-.715-.479-1.187-.479-.472 0-.868.16-1.187.48-.32.319-.48.714-.48 1.187 0 .472.16.868.48 1.187Z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </div>\n\n <div\n [class.lbm-message--open]=\"isLowBandwidth() && isIndicatorExpanded()\"\n class=\"lbm-message\"\n >\n <p class=\"lbm-description\">\n Fluid Interactivity Modes were disabled due to an unstable connection.\n\n <button\n (click)=\"openLBMDialog()\"\n [attr.data-testid]=\"'learn-more-lbm'\"\n type=\"button\"\n class=\"lbm-learn-more\"\n >\n Learn more\n </button>\n </p>\n <button\n (click)=\"toggleIndicator(false)\"\n [attr.data-testid]=\"'close-lbm-indicator'\"\n type=\"button\"\n class=\"lbm-close\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n >\n <path\n d=\"M10.0001 11.2289L6.68618 14.5428C6.51951 14.7095 6.31818 14.7895 6.08218 14.7828C5.84618 14.7755 5.64485 14.6885 5.47818 14.5218C5.31151 14.3552 5.22818 14.1505 5.22818 13.9078C5.22818 13.6645 5.31151 13.4595 5.47818 13.2928L8.77107 9.99992L5.45718 6.68603C5.29051 6.51936 5.21051 6.3147 5.21718 6.07203C5.22451 5.8287 5.31151 5.6237 5.47818 5.45703C5.64485 5.29036 5.84951 5.20703 6.09218 5.20703C6.33551 5.20703 6.54051 5.29036 6.70718 5.45703L10.0001 8.77092L13.314 5.45703C13.4806 5.29036 13.6853 5.20703 13.928 5.20703C14.1713 5.20703 14.3763 5.29036 14.543 5.45703C14.7096 5.6237 14.793 5.8287 14.793 6.07203C14.793 6.3147 14.7096 6.51936 14.543 6.68603L11.2291 9.99992L14.543 13.3138C14.7096 13.4805 14.793 13.6818 14.793 13.9178C14.793 14.1538 14.7096 14.3552 14.543 14.5218C14.3763 14.6885 14.1713 14.7718 13.928 14.7718C13.6853 14.7718 13.4806 14.6885 13.314 14.5218L10.0001 11.2289Z\"\n fill=\"white\"\n />\n </svg>\n </button>\n </div>\n </div>\n}\n\n@if (isDevMode) {\n <app-filter-settings />\n}\n", styles: [".freeze-loader,.lbm-indicator{--lbmPositionTop: 18px;--lbmPositionLeft: 18px;--lbmPositionRight: 18px;--lbmIndicatorSize: 36px;--lbmIndicatorBorderRadius: 8px;--lbmIndicatorCloseSize: 20px;--lbmPadding: 10px;--lbmTextSize: 13px;--lbmLineHeight: 20px;--lbmTextColor: #ffffff;--lbmBackgroundColor: rgba(17, 24, 39, .7);position:absolute;top:var(--lbmPositionTop);left:var(--lbmPositionLeft);z-index:10;display:flex;justify-content:center;align-items:flex-start;min-width:var(--lbmIndicatorSize);min-height:var(--lbmIndicatorSize);overflow:hidden}.freeze-loader.expanded,.lbm-indicator.expanded{--lbmPositionTop: 8px;--lbmPositionLeft: 8px;--lbmPositionRight: 8px;width:auto;max-width:calc(100% - var(--lbmPositionLeft) - var(--lbmPositionRight));padding:var(--lbmPadding);background:var(--lbmBackgroundColor);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:var(--lbmIndicatorBorderRadius);gap:12px}.freeze-loader .lbm-icon,.lbm-indicator .lbm-icon{display:flex;width:var(--lbmIndicatorSize);height:var(--lbmIndicatorSize);padding:8px;color:#fff;background-color:var(--lbmBackgroundColor);border-radius:50%;flex-shrink:0;cursor:pointer}.freeze-loader .lbm-icon svg,.freeze-loader .lbm-icon img,.lbm-indicator .lbm-icon svg,.lbm-indicator .lbm-icon img{width:100%;height:100%}.freeze-loader .lbm-close,.lbm-indicator .lbm-close{position:absolute;top:0;right:0;width:var(--lbmIndicatorCloseSize);height:var(--lbmIndicatorCloseSize);background-color:transparent;border:none;padding:0;cursor:pointer}.freeze-loader .lbm-message,.lbm-indicator .lbm-message{position:relative;opacity:0;height:0;max-width:0;padding-right:24px;pointer-events:none;visibility:hidden;animation:closeMessage .3s forwards}.freeze-loader .lbm-message--open,.lbm-indicator .lbm-message--open{position:relative;visibility:visible;display:flex;align-items:center;align-self:center;pointer-events:all;gap:12px;max-width:fit-content;height:auto;opacity:1;animation:openMessage .3s forwards}.freeze-loader .lbm-description,.lbm-indicator .lbm-description{margin:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor)}.freeze-loader .lbm-learn-more,.lbm-indicator .lbm-learn-more{display:inline-flex;padding:0;font-size:var(--lbmTextSize);font-weight:400;line-height:var(--lbmLineHeight);color:var(--lbmTextColor);-webkit-text-decoration-line:underline;text-decoration-line:underline;background-color:transparent;border:none;cursor:pointer}.freeze-loader .lbm-learn-more:hover,.lbm-indicator .lbm-learn-more:hover{text-decoration:none}@keyframes openMessage{0%{opacity:0;max-width:0;height:0}10%{height:auto;max-width:fit-content}to{opacity:1;height:auto;max-width:fit-content}}@keyframes closeMessage{0%{opacity:1;max-width:fit-content}to{opacity:0;max-width:0}}\n"] }]
6155
+ }] });
6145
6156
 
6146
6157
  function provideAngularUnrealModule(config) {
6147
6158
  return makeEnvironmentProviders([
@@ -6151,18 +6162,23 @@ function provideAngularUnrealModule(config) {
6151
6162
  ConsoleExtensionsService,
6152
6163
  InputService,
6153
6164
  VideoService,
6165
+ RegionsPingService,
6166
+ FileReceiverService,
6167
+ FileHandlerService,
6168
+ AnalyticsService,
6169
+ UnrealCallbackService,
6154
6170
  {
6155
6171
  provide: WebRtcPlayerService,
6156
6172
  useClass: config?.newWebRtcConnector
6157
6173
  ? WebRtcPlayerService2
6158
6174
  : WebRtcPlayerService,
6159
6175
  },
6160
- RegionsPingService,
6161
- FileReceiverService,
6162
- FileHandlerService,
6163
- FpsMonitorService,
6164
- AnalyticsService,
6165
- UnrealCallbackService,
6176
+ {
6177
+ provide: FpsMonitorService,
6178
+ useClass: config?.playwright || config?.fpsMonitor
6179
+ ? FpsMonitorPlaywrightService
6180
+ : FpsMonitorService,
6181
+ },
6166
6182
  {
6167
6183
  provide: StreamStatusTelemetryService,
6168
6184
  useClass: config?.playwright
@@ -6232,5 +6248,5 @@ function provideAngularUnrealModule(config) {
6232
6248
  * Generated bundle index. Do not edit.
6233
6249
  */
6234
6250
 
6235
- export { AFKService, AfkPlaywrightService, AfkTimeoutModalComponent, AggregatorPlaywrightService, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryPlaywrightService, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsPlaywrightService, 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, FileReceiverPlaywrightService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFramePlaywrightService, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputPlaywrightService, 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, SignallingPlaywrightService, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryPlaywrightService, StreamStatusTelemetryService, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorPlaywrightService, 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, getRtcErrorMessage, iceConnectionFailed, initSignalling, keepMaxUntilReset, provideAngularUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsShowPopupWithoutAutoStart, selectIsVideoPlayingAndDataChannelConnected, selectLastCommandInProgress, 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 };
6251
+ export { AFKService, AfkPlaywrightService, AfkTimeoutModalComponent, AggregatorPlaywrightService, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryPlaywrightService, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsPlaywrightService, 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, FileReceiverPlaywrightService, FileReceiverService, FilterSettingsComponent, FpsMonitorPlaywrightService, FpsMonitorService, FreezeFrameComponent, FreezeFramePlaywrightService, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputPlaywrightService, 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, SignallingPlaywrightService, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryPlaywrightService, StreamStatusTelemetryService, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorPlaywrightService, 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, WebRtcPlayerService2, 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, getRtcErrorMessage, iceConnectionFailed, initSignalling, keepMaxUntilReset, provideAngularUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsShowPopupWithoutAutoStart, selectIsVideoPlayingAndDataChannelConnected, selectLastCommandInProgress, 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 };
6236
6252
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map