@3dsource/angular-unreal-module 0.0.107 → 0.0.109

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.
@@ -2878,103 +2878,243 @@ class WebRtcPlayerService extends SubService {
2878
2878
  this.useMic = false;
2879
2879
  this.forceTURN = false;
2880
2880
  this.forceMonoAudio = false;
2881
+ /** Whether the remote SDP has been applied to the current peer connection */
2882
+ this.remoteDescriptionSet = false;
2883
+ /** ICE candidates received before the remote description was set */
2884
+ this.iceCandidateBuffer = [];
2881
2885
  this.isReconnecting = this.store.selectSignal(unrealFeature.selectIsReconnecting);
2882
2886
  this.init();
2883
2887
  this.actions$.pipe(ofType(dropDC)).subscribe(() => this.dcClient?.close());
2884
2888
  this.actions$.pipe(ofType(dropPC)).subscribe(() => this.pcClient?.close());
2885
2889
  }
2886
2890
  init() {
2887
- const iceCandidateBuffer = [];
2888
- const processBuffer = () => {
2889
- iceCandidateBuffer.forEach((cnd) => {
2890
- this.handleCandidateFromServer(cnd);
2891
- });
2892
- iceCandidateBuffer.length = 0;
2893
- };
2894
2891
  this.signallingSrv.onConfig$.subscribe((config) => {
2895
2892
  this.videoService.create();
2896
2893
  this.setConfig(config);
2897
2894
  });
2895
+ // Server-initiated offer: set remote description and respond with answer
2896
+ this.signallingSrv.onOffer$.pipe(filter(Truthy)).subscribe((offer) => {
2897
+ void this.handleServerOffer(offer);
2898
+ });
2899
+ // Answer to a client-initiated offer
2898
2900
  this.signallingSrv.onWebRtcAnswer$
2899
2901
  .pipe(filter(Truthy))
2900
2902
  .subscribe((answer) => {
2901
- this.receiveAnswer(answer);
2902
- processBuffer();
2903
- });
2904
- this.signallingSrv.onOffer$.pipe(filter(Truthy)).subscribe(() => {
2905
- void this.createWebRtcOffer();
2903
+ void this.handleServerAnswer(answer);
2906
2904
  });
2907
- this.signallingSrv.onWebRtcIce$
2908
- .pipe(withLatestFrom(this.signallingSrv.onWebRtcAnswer$))
2909
- .subscribe(([candidate, answerEstablished]) => {
2910
- this.commandTelemetryService.trackStopCommand('EXT-iceCandidate', true, { multi: true });
2911
- iceCandidateBuffer.push(candidate);
2912
- if (answerEstablished) {
2913
- processBuffer();
2905
+ // ICE candidates — buffered until remote description is set
2906
+ this.signallingSrv.onWebRtcIce$.subscribe((candidate) => {
2907
+ this.commandTelemetryService.trackStopCommand('EXT-iceCandidate', true, {
2908
+ multi: true,
2909
+ });
2910
+ if (this.remoteDescriptionSet) {
2911
+ this.handleCandidateFromServer(candidate);
2912
+ }
2913
+ else {
2914
+ this.iceCandidateBuffer.push(candidate);
2914
2915
  }
2915
2916
  });
2916
2917
  }
2918
+ /**
2919
+ * Handles an SDP offer received from the server.
2920
+ * Creates a new peer connection, applies the remote offer,
2921
+ * then responds with an SDP answer.
2922
+ *
2923
+ * The DataChannel is NOT created here — the server creates it,
2924
+ * and we receive it via the `datachannel` event on the peer connection.
2925
+ */
2926
+ async handleServerOffer(offer) {
2927
+ Logger.info('Received offer from server, creating answer');
2928
+ try {
2929
+ this.remoteDescriptionSet = false;
2930
+ this.iceCandidateBuffer.length = 0;
2931
+ if (!this.pcClient) {
2932
+ this.setupPeerConnection(this.cfg);
2933
+ }
2934
+ // Offer already defines media transceivers — don't add extras
2935
+ await this.pcClient?.setRemoteDescription(offer);
2936
+ this.remoteDescriptionSet = true;
2937
+ this.flushIceCandidateBuffer();
2938
+ await this.createAnswer(this.pcClient);
2939
+ }
2940
+ catch (e) {
2941
+ Logger.error('Failed to handle server offer', e);
2942
+ this.store.dispatch(disconnectStream({
2943
+ reason: DisconnectReason.WebRTCError,
2944
+ message: 'WebRTCError: Failed to handle server offer',
2945
+ }));
2946
+ }
2947
+ }
2948
+ /**
2949
+ * Handles an SDP answer received from the server in response to our offer.
2950
+ * Applies the remote description and flushes any buffered ICE candidates.
2951
+ */
2952
+ async handleServerAnswer(answer) {
2953
+ Logger.info('Received answer from server');
2954
+ try {
2955
+ await this.pcClient?.setRemoteDescription(answer);
2956
+ this.remoteDescriptionSet = true;
2957
+ this.flushIceCandidateBuffer();
2958
+ }
2959
+ catch (e) {
2960
+ Logger.error('Failed to set remote description from answer', e);
2961
+ }
2962
+ }
2963
+ /** Drains buffered ICE candidates after the remote description has been set */
2964
+ flushIceCandidateBuffer() {
2965
+ for (const candidate of this.iceCandidateBuffer) {
2966
+ this.handleCandidateFromServer(candidate);
2967
+ }
2968
+ this.iceCandidateBuffer.length = 0;
2969
+ }
2970
+ /**
2971
+ * Creates a client-initiated WebRTC offer and sends it to the server.
2972
+ * When the client offers, it also creates the DataChannel.
2973
+ * Used for peer reconnection through the existing signaling connection.
2974
+ */
2917
2975
  async createWebRtcOffer() {
2918
- Logger.info('Creating offer');
2919
- let offer;
2976
+ Logger.info('Creating client-initiated offer');
2920
2977
  try {
2921
- offer = await this.createPeerConnection(this.cfg);
2978
+ this.remoteDescriptionSet = false;
2979
+ this.iceCandidateBuffer.length = 0;
2980
+ if (!this.pcClient) {
2981
+ this.setupPeerConnection(this.cfg);
2982
+ }
2983
+ // Client offers: transceivers first, then DC, then offer
2984
+ await this.setupTransceiversAsync(this.pcClient);
2985
+ this.dcClient = this.createDataChannel(this.pcClient, 'UnrealDcConnection', { ordered: true });
2986
+ await this.createOffer(this.pcClient);
2922
2987
  }
2923
- catch {
2988
+ catch (e) {
2989
+ Logger.error('Failed to create WebRTC offer', e);
2924
2990
  this.store.dispatch(disconnectStream({
2925
2991
  reason: DisconnectReason.WebRTCError,
2926
2992
  message: 'WebRTCError: Offer Error',
2927
2993
  }));
2928
2994
  }
2929
- return offer;
2995
+ }
2996
+ /**
2997
+ * Initializes a new RTCPeerConnection with event handlers.
2998
+ * Transceivers are NOT added here — the caller is responsible for adding
2999
+ * them at the correct point depending on the negotiation flow.
3000
+ */
3001
+ setupPeerConnection(config) {
3002
+ this.closePC();
3003
+ Logger.log('Creating new RTCPeerConnection with config');
3004
+ this.pcClient = new RTCPeerConnection(config);
3005
+ this.commandTelemetryService.trackStartCommand('EXT-Browser ICE gathering');
3006
+ this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
3007
+ this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
3008
+ this.handlePeerConnectionEvents(this.pcClient);
3009
+ this.disconnect$.pipe(take(1)).subscribe(() => {
3010
+ this.closePC();
3011
+ });
2930
3012
  }
2931
3013
  createDataChannel(pc, label, options) {
2932
- // This is the primary data channel code path when we are "offering"
2933
3014
  const datachannel = pc.createDataChannel(label, options);
2934
3015
  return this.setupDataChannelCallbacks(datachannel);
2935
3016
  }
2936
- receiveAnswer(answer) {
2937
- this.pcClient?.setRemoteDescription(answer);
2938
- }
2939
3017
  handleCandidateFromServer(iceCandidate) {
2940
3018
  try {
2941
3019
  const candidate = new RTCIceCandidate(iceCandidate);
2942
3020
  Logger.info('[Unreal ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
2943
- // if forcing TURN, reject any candidates not relay
3021
+ // If forcing TURN, reject any candidates not relay
2944
3022
  if (this.forceTURN) {
2945
- // check if no relay address is found, if so, we are assuming it means no TURN server
2946
3023
  if (candidate.candidate.indexOf('relay') < 0) {
2947
3024
  Logger.warn('Dropping candidate because it was not TURN relay.', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
2948
3025
  return;
2949
3026
  }
2950
3027
  }
2951
- this.pcClient?.addIceCandidate(candidate).catch(function () {
2952
- // Logger.error('Failed to add ICE candidate', e);
3028
+ this.pcClient?.addIceCandidate(candidate).catch((e) => {
3029
+ Logger.warn('Failed to add ICE candidate', e);
2953
3030
  });
2954
3031
  }
2955
3032
  catch (e) {
2956
3033
  Logger.error('Error handleCandidateFromServer', e);
2957
3034
  }
2958
3035
  }
2959
- mungeSDP(offer) {
2960
- let audioSDP = '';
2961
- // set max bitrate to the highest bitrate Opus supports
2962
- audioSDP += 'maxaveragebitrate=510000;';
3036
+ /**
3037
+ * Reorders the `m=video` line in the SDP so that payload types matching
3038
+ * the given H.264 `profile-level-id` appear first, making the browser
3039
+ * prefer that profile during codec negotiation.
3040
+ *
3041
+ * A `profile-level-id` is a 3-byte hex string (`XXYYZZ`) defined in
3042
+ * RFC 6184 that encodes three H.264 parameters:
3043
+ * - **Profile IDC** (`XX`) — the H.264 profile.
3044
+ * - **Profile IOP** (`YY`) — constraint flags that further narrow the profile.
3045
+ * - **Level IDC** (`ZZ`) — the level, which caps resolution/framerate/bitrate.
3046
+ *
3047
+ * ### H.264 profiles relevant to Unreal Engine Pixel Streaming
3048
+ *
3049
+ * | profile-level-id | Profile | Level | Notes |
3050
+ * |------------------|----------------------------|-------|----------------------------------------------|
3051
+ * | `42001f` | Baseline | 3.1 | No B-frames, low latency; legacy devices |
3052
+ * | `42e01f` | Constrained Baseline | 3.1 | Widest HW decode support; **recommended** |
3053
+ * | `4d001f` | Main | 3.1 | B-frames + CABAC; better compression |
3054
+ * | `4d4028` | Main | 4.0 | Up to 1080p60; common desktop target |
3055
+ * | `640028` | High | 4.0 | Best quality/compression; most GPUs support |
3056
+ * | `64002a` | High | 4.2 | Up to 1080p120 or 4K30 |
3057
+ * | `640033` | High | 5.1 | Up to 4K60; requires modern HW |
3058
+ * | `640034` | High | 5.2 | Up to 4K120; high-end HW only |
3059
+ * | `6e001f` | High 10 | 3.1 | 10-bit color; limited browser support |
3060
+ * | `f4001f` | High 4:4:4 Predictive | 3.1 | Lossless capable; rare in browsers |
3061
+ *
3062
+ * UE Pixel Streaming defaults to Constrained Baseline (`42e01f`) or High (`6400xx`)
3063
+ * depending on engine version and `WebRTC.H264Profile` CVar.
3064
+ *
3065
+ * We prioritize `42e01f` because it guarantees hardware decoding on
3066
+ * virtually all client devices (desktop, mobile, embedded), avoiding
3067
+ * fallback to software decoding or negotiation of a higher profile
3068
+ * that the client GPU may not accelerate.
3069
+ */
3070
+ prioritizeH264Profile(sdp, profileLevelId) {
3071
+ const lines = sdp.split('\r\n');
3072
+ const targetPts = [];
3073
+ for (const line of lines) {
3074
+ if (line.startsWith('a=fmtp:') &&
3075
+ line.toLowerCase().includes(`profile-level-id=${profileLevelId}`)) {
3076
+ const pt = line.split(':')[1]?.split(' ')[0];
3077
+ if (pt)
3078
+ targetPts.push(pt);
3079
+ }
3080
+ }
3081
+ if (targetPts.length === 0)
3082
+ return sdp;
3083
+ return lines
3084
+ .map((line) => {
3085
+ if (!line.startsWith('m=video'))
3086
+ return line;
3087
+ const parts = line.split(' ');
3088
+ // parts: m=video <port> <proto> <pt1> <pt2> ...
3089
+ const header = parts.slice(0, 3);
3090
+ const pts = parts.slice(3);
3091
+ const prioritized = [
3092
+ ...targetPts.filter((pt) => pts.includes(pt)),
3093
+ ...pts.filter((pt) => !targetPts.includes(pt)),
3094
+ ];
3095
+ return [...header, ...prioritized].join(' ');
3096
+ })
3097
+ .join('\r\n');
3098
+ }
3099
+ mungeSDP(sdp) {
3100
+ let mungedSDP = this.prioritizeH264Profile(sdp, '42e01f');
3101
+ 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');
3102
+ // Set max bitrate to highest bitrate Opus supports
3103
+ let audioSDP = 'maxaveragebitrate=510000;';
2963
3104
  if (this.useMic) {
2964
- // set the max capture rate to 48khz (so we can send high-quality audio from mic)
3105
+ // Set the max capture rate to 48khz (so we can send high-quality audio from mic)
2965
3106
  audioSDP += 'sprop-maxcapturerate=48000;';
2966
3107
  }
2967
3108
  // Force mono or stereo based on whether ?forceMono was passed or not
2968
- audioSDP += this.forceMonoAudio
2969
- ? 'sprop-stereo=0;stereo=0;'
2970
- : 'sprop-stereo=1;stereo=1;';
2971
- // enable in-band forward error correction for opus audio
3109
+ audioSDP += this.forceMonoAudio ? 'stereo=0;' : 'stereo=1;';
3110
+ // Enable in-band forward error correction for opus audio
2972
3111
  audioSDP += 'useinbandfec=1';
2973
3112
  // We use the line 'useinbandfec=1' (which Opus uses) to set our Opus specific audio parameters.
2974
- offer.sdp = offer.sdp?.replace('useinbandfec=1', audioSDP);
3113
+ mungedSDP = mungedSDP?.replace('useinbandfec=1', audioSDP);
3114
+ return mungedSDP;
2975
3115
  }
2976
3116
  /**
2977
- * Closes existing PeerConnection
3117
+ * Closes existing PeerConnection and DataChannel, resets negotiation state.
2978
3118
  */
2979
3119
  closePC() {
2980
3120
  if (this.pcClient) {
@@ -2987,6 +3127,8 @@ class WebRtcPlayerService extends SubService {
2987
3127
  }
2988
3128
  this.dcClient?.close();
2989
3129
  this.dcClient = null;
3130
+ this.remoteDescriptionSet = false;
3131
+ this.iceCandidateBuffer.length = 0;
2990
3132
  }
2991
3133
  canSend() {
2992
3134
  return this.dcClient && this.dcClient.readyState === 'open';
@@ -3023,9 +3165,9 @@ class WebRtcPlayerService extends SubService {
3023
3165
  }
3024
3166
  }
3025
3167
  /**
3026
- * Reconnect only the WebRTC peer connection without re-establishing signaling.
3027
- * This closes the current PeerConnection and creates a new offer through
3028
- * the existing WebSocket connection.
3168
+ * Reconnects only the WebRTC peer connection without re-establishing signaling.
3169
+ * Closes the current PeerConnection and creates a new client-initiated offer
3170
+ * through the existing WebSocket connection.
3029
3171
  * @returns true if reconnection was initiated, false if signaling is not connected
3030
3172
  */
3031
3173
  reconnectPeer() {
@@ -3058,11 +3200,7 @@ class WebRtcPlayerService extends SubService {
3058
3200
  this.cfg.bundlePolicy = urlParams.has('ForceMaxBundle')
3059
3201
  ? 'max-bundle'
3060
3202
  : 'balanced';
3061
- //**********************
3062
- //Variables
3063
- //**********************
3064
- this.pcClient = null;
3065
- this.dcClient = null;
3203
+ this.cfg.iceCandidatePoolSize = 1;
3066
3204
  // When ?useMic check for SSL or localhost
3067
3205
  const isLocalhostConnection = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
3068
3206
  const isHttpsConnection = location.protocol === 'https:';
@@ -3071,23 +3209,7 @@ class WebRtcPlayerService extends SubService {
3071
3209
  Logger.error('Microphone access in the browser will not work if you are not on HTTPS or localhost. Disabling mic access.');
3072
3210
  Logger.error("For testing you can enable HTTP microphone access Chrome by visiting chrome://flags/ and enabling 'unsafely-treat-insecure-origin-as-secure'");
3073
3211
  }
3074
- }
3075
- async createPeerConnection(config) {
3076
- this.closePC();
3077
- this.pcClient = new RTCPeerConnection(config);
3078
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE gathering');
3079
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
3080
- this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
3081
- this.handlePeerConnectionEvents(this.pcClient);
3082
- await this.setupTransceiversAsync(this.pcClient);
3083
- this.dcClient = this.createDataChannel(this.pcClient, 'UnrealDcConnection', {
3084
- // See https://www.w3.org/TR/webrtc/#dom-rtcdatachannelinit for values (this is needed for Firefox to be consistent with Chrome.)
3085
- ordered: true,
3086
- });
3087
- this.disconnect$.pipe(take(1)).subscribe(() => {
3088
- this.closePC();
3089
- });
3090
- await this.createOffer(this.pcClient);
3212
+ this.setupPeerConnection(this.cfg);
3091
3213
  }
3092
3214
  handlePeerConnectionEvents(pcClient) {
3093
3215
  fromEvent(pcClient, 'signalingstatechange')
@@ -3116,8 +3238,10 @@ class WebRtcPlayerService extends SubService {
3116
3238
  fromEvent(pcClient, 'icecandidate')
3117
3239
  .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$))
3118
3240
  .subscribe((candidate) => this.signallingSrv.send({ type: 'iceCandidate', candidate }));
3241
+ // When the server creates the DataChannel (server-offer flow),
3242
+ // we receive it here via the 'datachannel' event
3119
3243
  fromEvent(pcClient, 'datachannel')
3120
- .pipe(tap$1(() => Logger.info('Data channel created for us by browser as we are a receiving peer.')), map$1((e) => e.channel), takeUntil(this.disconnect$))
3244
+ .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$))
3121
3245
  .subscribe((datachannel) => {
3122
3246
  this.dcClient = this.setupDataChannelCallbacks(datachannel);
3123
3247
  });
@@ -3225,7 +3349,14 @@ class WebRtcPlayerService extends SubService {
3225
3349
  this.onDataChannelConnected();
3226
3350
  });
3227
3351
  fromEvent(datachannel, 'message')
3228
- .pipe(map$1((e) => e.data), takeUntil(this.disconnect$))
3352
+ .pipe(
3353
+ /* tap((e: MessageEvent) => {
3354
+ const view = new Uint8Array(e.data);
3355
+ Logger.info(
3356
+ `[DATACHANNEL] Message received on "${datachannel.label}" id=${datachannel.id}, byteCode=${view[0]}, size=${view.byteLength}`,
3357
+ );
3358
+ }),*/
3359
+ map$1((e) => e.data), takeUntil(this.disconnect$))
3229
3360
  .subscribe((data) => this.onDataChannelMessage$.next(data));
3230
3361
  return datachannel;
3231
3362
  }
@@ -3242,20 +3373,38 @@ class WebRtcPlayerService extends SubService {
3242
3373
  }
3243
3374
  this.store.dispatch(dataChannelConnected({ statusMessage }));
3244
3375
  }
3376
+ /**
3377
+ * Creates an SDP offer, applies SDP munging, sets it as a local description,
3378
+ * and sends it to the server via signaling.
3379
+ * Transceivers already configure media directions, so deprecated
3380
+ * offerToReceive* options are not used.
3381
+ */
3245
3382
  async createOffer(pcClient) {
3246
3383
  try {
3247
- const offer = await pcClient.createOffer({
3248
- offerToReceiveAudio: false, //Note: if you don't need audio, you can get improved latency by turning this off.
3249
- offerToReceiveVideo: true,
3250
- });
3251
- this.mungeSDP(offer);
3252
- pcClient?.setLocalDescription(offer);
3384
+ const offer = await pcClient.createOffer();
3385
+ offer.sdp = this.mungeSDP(offer.sdp ?? '');
3386
+ await pcClient.setLocalDescription(offer);
3253
3387
  this.signallingSrv.send(offer);
3254
3388
  }
3255
3389
  catch {
3256
3390
  Logger.error("Couldn't create offer");
3257
3391
  }
3258
3392
  }
3393
+ /**
3394
+ * Creates an SDP answer, applies SDP munging, sets it as a local description,
3395
+ * and sends it to the server via signaling.
3396
+ */
3397
+ async createAnswer(pcClient) {
3398
+ try {
3399
+ const answer = await pcClient.createAnswer();
3400
+ answer.sdp = this.mungeSDP(answer.sdp ?? '');
3401
+ await pcClient.setLocalDescription(answer);
3402
+ this.signallingSrv.send(answer);
3403
+ }
3404
+ catch (e) {
3405
+ Logger.error("Couldn't create answer", e);
3406
+ }
3407
+ }
3259
3408
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: WebRtcPlayerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
3260
3409
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: WebRtcPlayerService }); }
3261
3410
  }
@@ -4458,556 +4607,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
4458
4607
  type: Injectable
4459
4608
  }], ctorParameters: () => [] });
4460
4609
 
4461
- class WebRtcPlayerService2 extends SubService {
4462
- constructor() {
4463
- super();
4464
- this.onDataChannelMessage$ = new Subject();
4465
- this.signallingSrv = inject(SignallingService);
4466
- this.videoService = inject(VideoService);
4467
- this.commandTelemetryService = inject(CommandTelemetryService);
4468
- this.cfg = {};
4469
- this.useMic = false;
4470
- this.forceTURN = false;
4471
- this.forceMonoAudio = false;
4472
- /** Whether the remote SDP has been applied to the current peer connection */
4473
- this.remoteDescriptionSet = false;
4474
- /** ICE candidates received before the remote description was set */
4475
- this.iceCandidateBuffer = [];
4476
- this.isReconnecting = this.store.selectSignal(unrealFeature.selectIsReconnecting);
4477
- this.init();
4478
- this.actions$.pipe(ofType(dropDC)).subscribe(() => this.dcClient?.close());
4479
- this.actions$.pipe(ofType(dropPC)).subscribe(() => this.pcClient?.close());
4480
- }
4481
- init() {
4482
- this.signallingSrv.onConfig$.subscribe((config) => {
4483
- this.videoService.create();
4484
- this.setConfig(config);
4485
- });
4486
- // Server-initiated offer: set remote description and respond with answer
4487
- this.signallingSrv.onOffer$.pipe(filter(Truthy)).subscribe((offer) => {
4488
- void this.handleServerOffer(offer);
4489
- });
4490
- // Answer to a client-initiated offer
4491
- this.signallingSrv.onWebRtcAnswer$
4492
- .pipe(filter(Truthy))
4493
- .subscribe((answer) => {
4494
- void this.handleServerAnswer(answer);
4495
- });
4496
- // ICE candidates — buffered until remote description is set
4497
- this.signallingSrv.onWebRtcIce$.subscribe((candidate) => {
4498
- this.commandTelemetryService.trackStopCommand('EXT-iceCandidate', true, {
4499
- multi: true,
4500
- });
4501
- if (this.remoteDescriptionSet) {
4502
- this.handleCandidateFromServer(candidate);
4503
- }
4504
- else {
4505
- this.iceCandidateBuffer.push(candidate);
4506
- }
4507
- });
4508
- }
4509
- /**
4510
- * Handles an SDP offer received from the server.
4511
- * Creates a new peer connection, applies the remote offer,
4512
- * then responds with an SDP answer.
4513
- *
4514
- * The DataChannel is NOT created here — the server creates it,
4515
- * and we receive it via the `datachannel` event on the peer connection.
4516
- */
4517
- async handleServerOffer(offer) {
4518
- Logger.info('Received offer from server, creating answer');
4519
- try {
4520
- this.remoteDescriptionSet = false;
4521
- this.iceCandidateBuffer.length = 0;
4522
- if (!this.pcClient) {
4523
- this.setupPeerConnection(this.cfg);
4524
- }
4525
- // Offer already defines media transceivers — don't add extras
4526
- await this.pcClient?.setRemoteDescription(offer);
4527
- this.remoteDescriptionSet = true;
4528
- this.flushIceCandidateBuffer();
4529
- await this.createAnswer(this.pcClient);
4530
- }
4531
- catch (e) {
4532
- Logger.error('Failed to handle server offer', e);
4533
- this.store.dispatch(disconnectStream({
4534
- reason: DisconnectReason.WebRTCError,
4535
- message: 'WebRTCError: Failed to handle server offer',
4536
- }));
4537
- }
4538
- }
4539
- /**
4540
- * Handles an SDP answer received from the server in response to our offer.
4541
- * Applies the remote description and flushes any buffered ICE candidates.
4542
- */
4543
- async handleServerAnswer(answer) {
4544
- Logger.info('Received answer from server');
4545
- try {
4546
- await this.pcClient?.setRemoteDescription(answer);
4547
- this.remoteDescriptionSet = true;
4548
- this.flushIceCandidateBuffer();
4549
- }
4550
- catch (e) {
4551
- Logger.error('Failed to set remote description from answer', e);
4552
- }
4553
- }
4554
- /** Drains buffered ICE candidates after the remote description has been set */
4555
- flushIceCandidateBuffer() {
4556
- for (const candidate of this.iceCandidateBuffer) {
4557
- this.handleCandidateFromServer(candidate);
4558
- }
4559
- this.iceCandidateBuffer.length = 0;
4560
- }
4561
- /**
4562
- * Creates a client-initiated WebRTC offer and sends it to the server.
4563
- * When the client offers, it also creates the DataChannel.
4564
- * Used for peer reconnection through the existing signaling connection.
4565
- */
4566
- async createWebRtcOffer() {
4567
- Logger.info('Creating client-initiated offer');
4568
- try {
4569
- this.remoteDescriptionSet = false;
4570
- this.iceCandidateBuffer.length = 0;
4571
- if (!this.pcClient) {
4572
- this.setupPeerConnection(this.cfg);
4573
- }
4574
- // Client offers: transceivers first, then DC, then offer
4575
- await this.setupTransceiversAsync(this.pcClient);
4576
- this.dcClient = this.createDataChannel(this.pcClient, 'UnrealDcConnection', { ordered: true });
4577
- await this.createOffer(this.pcClient);
4578
- }
4579
- catch (e) {
4580
- Logger.error('Failed to create WebRTC offer', e);
4581
- this.store.dispatch(disconnectStream({
4582
- reason: DisconnectReason.WebRTCError,
4583
- message: 'WebRTCError: Offer Error',
4584
- }));
4585
- }
4586
- }
4587
- /**
4588
- * Initializes a new RTCPeerConnection with event handlers.
4589
- * Transceivers are NOT added here — the caller is responsible for adding
4590
- * them at the correct point depending on the negotiation flow.
4591
- */
4592
- setupPeerConnection(config) {
4593
- this.closePC();
4594
- Logger.log('Creating new RTCPeerConnection with config');
4595
- this.pcClient = new RTCPeerConnection(config);
4596
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE gathering');
4597
- this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
4598
- this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
4599
- this.handlePeerConnectionEvents(this.pcClient);
4600
- this.disconnect$.pipe(take(1)).subscribe(() => {
4601
- this.closePC();
4602
- });
4603
- }
4604
- createDataChannel(pc, label, options) {
4605
- const datachannel = pc.createDataChannel(label, options);
4606
- return this.setupDataChannelCallbacks(datachannel);
4607
- }
4608
- handleCandidateFromServer(iceCandidate) {
4609
- try {
4610
- const candidate = new RTCIceCandidate(iceCandidate);
4611
- Logger.info('[Unreal ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
4612
- // If forcing TURN, reject any candidates not relay
4613
- if (this.forceTURN) {
4614
- if (candidate.candidate.indexOf('relay') < 0) {
4615
- Logger.warn('Dropping candidate because it was not TURN relay.', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|');
4616
- return;
4617
- }
4618
- }
4619
- this.pcClient?.addIceCandidate(candidate).catch((e) => {
4620
- Logger.warn('Failed to add ICE candidate', e);
4621
- });
4622
- }
4623
- catch (e) {
4624
- Logger.error('Error handleCandidateFromServer', e);
4625
- }
4626
- }
4627
- /**
4628
- * Reorders the `m=video` line in the SDP so that payload types matching
4629
- * the given H.264 `profile-level-id` appear first, making the browser
4630
- * prefer that profile during codec negotiation.
4631
- *
4632
- * A `profile-level-id` is a 3-byte hex string (`XXYYZZ`) defined in
4633
- * RFC 6184 that encodes three H.264 parameters:
4634
- * - **Profile IDC** (`XX`) — the H.264 profile.
4635
- * - **Profile IOP** (`YY`) — constraint flags that further narrow the profile.
4636
- * - **Level IDC** (`ZZ`) — the level, which caps resolution/framerate/bitrate.
4637
- *
4638
- * ### H.264 profiles relevant to Unreal Engine Pixel Streaming
4639
- *
4640
- * | profile-level-id | Profile | Level | Notes |
4641
- * |------------------|----------------------------|-------|----------------------------------------------|
4642
- * | `42001f` | Baseline | 3.1 | No B-frames, low latency; legacy devices |
4643
- * | `42e01f` | Constrained Baseline | 3.1 | Widest HW decode support; **recommended** |
4644
- * | `4d001f` | Main | 3.1 | B-frames + CABAC; better compression |
4645
- * | `4d4028` | Main | 4.0 | Up to 1080p60; common desktop target |
4646
- * | `640028` | High | 4.0 | Best quality/compression; most GPUs support |
4647
- * | `64002a` | High | 4.2 | Up to 1080p120 or 4K30 |
4648
- * | `640033` | High | 5.1 | Up to 4K60; requires modern HW |
4649
- * | `640034` | High | 5.2 | Up to 4K120; high-end HW only |
4650
- * | `6e001f` | High 10 | 3.1 | 10-bit color; limited browser support |
4651
- * | `f4001f` | High 4:4:4 Predictive | 3.1 | Lossless capable; rare in browsers |
4652
- *
4653
- * UE Pixel Streaming defaults to Constrained Baseline (`42e01f`) or High (`6400xx`)
4654
- * depending on engine version and `WebRTC.H264Profile` CVar.
4655
- *
4656
- * We prioritize `42e01f` because it guarantees hardware decoding on
4657
- * virtually all client devices (desktop, mobile, embedded), avoiding
4658
- * fallback to software decoding or negotiation of a higher profile
4659
- * that the client GPU may not accelerate.
4660
- */
4661
- prioritizeH264Profile(sdp, profileLevelId) {
4662
- const lines = sdp.split('\r\n');
4663
- const targetPts = [];
4664
- for (const line of lines) {
4665
- if (line.startsWith('a=fmtp:') &&
4666
- line.toLowerCase().includes(`profile-level-id=${profileLevelId}`)) {
4667
- const pt = line.split(':')[1]?.split(' ')[0];
4668
- if (pt)
4669
- targetPts.push(pt);
4670
- }
4671
- }
4672
- if (targetPts.length === 0)
4673
- return sdp;
4674
- return lines
4675
- .map((line) => {
4676
- if (!line.startsWith('m=video'))
4677
- return line;
4678
- const parts = line.split(' ');
4679
- // parts: m=video <port> <proto> <pt1> <pt2> ...
4680
- const header = parts.slice(0, 3);
4681
- const pts = parts.slice(3);
4682
- const prioritized = [
4683
- ...targetPts.filter((pt) => pts.includes(pt)),
4684
- ...pts.filter((pt) => !targetPts.includes(pt)),
4685
- ];
4686
- return [...header, ...prioritized].join(' ');
4687
- })
4688
- .join('\r\n');
4689
- }
4690
- mungeSDP(sdp) {
4691
- let mungedSDP = this.prioritizeH264Profile(sdp, '42e01f');
4692
- 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');
4693
- // Set max bitrate to highest bitrate Opus supports
4694
- let audioSDP = 'maxaveragebitrate=510000;';
4695
- if (this.useMic) {
4696
- // Set the max capture rate to 48khz (so we can send high-quality audio from mic)
4697
- audioSDP += 'sprop-maxcapturerate=48000;';
4698
- }
4699
- // Force mono or stereo based on whether ?forceMono was passed or not
4700
- audioSDP += this.forceMonoAudio ? 'stereo=0;' : 'stereo=1;';
4701
- // Enable in-band forward error correction for opus audio
4702
- audioSDP += 'useinbandfec=1';
4703
- // We use the line 'useinbandfec=1' (which Opus uses) to set our Opus specific audio parameters.
4704
- mungedSDP = mungedSDP?.replace('useinbandfec=1', audioSDP);
4705
- return mungedSDP;
4706
- }
4707
- /**
4708
- * Closes existing PeerConnection and DataChannel, resets negotiation state.
4709
- */
4710
- closePC() {
4711
- if (this.pcClient) {
4712
- Logger.warn('Closing existing PeerConnection');
4713
- }
4714
- this.pcClient?.close();
4715
- this.pcClient = null;
4716
- if (this.dcClient) {
4717
- Logger.warn('Closing existing DataChannel');
4718
- }
4719
- this.dcClient?.close();
4720
- this.dcClient = null;
4721
- this.remoteDescriptionSet = false;
4722
- this.iceCandidateBuffer.length = 0;
4723
- }
4724
- canSend() {
4725
- return this.dcClient && this.dcClient.readyState === 'open';
4726
- }
4727
- send(data) {
4728
- if (this.canSend()) {
4729
- this.dcClient?.send(data);
4730
- }
4731
- else {
4732
- Logger.colored('black', 'yellow', `DataChannel connection not prepared or in state: ${this.dcClient?.readyState}`);
4733
- // Trigger peer reconnection if DC is not open and we're not yet reconnecting
4734
- this.tryReconnectOnSendFailure();
4735
- }
4736
- }
4737
- /**
4738
- * Attempts to reconnect the peer when send fails due to DataChannel not being open.
4739
- * This handles cases where the DC connection is broken, but no error events are fired.
4740
- */
4741
- tryReconnectOnSendFailure() {
4742
- // Skip if already reconnecting or signaling is not connected
4743
- if (this.isReconnecting()) {
4744
- Logger.info('[Reconnect] Already reconnecting, skipping duplicate attempt');
4745
- return;
4746
- }
4747
- if (!this.signallingSrv.isConnected()) {
4748
- Logger.warn('[Reconnect] Cannot reconnect: Signaling not connected');
4749
- return;
4750
- }
4751
- // Check if DC exists but is in a non-open state (closing, closed, or connecting stuck)
4752
- const dcState = this.dcClient?.readyState;
4753
- if (dcState && dcState !== 'open') {
4754
- Logger.warn(`[Reconnect] DataChannel in "${dcState}" state during send, initiating peer reconnection`);
4755
- this.store.dispatch(reconnectPeer({ attempt: 1, maxAttempts: 3, delay: 0 }));
4756
- }
4757
- }
4758
- /**
4759
- * Reconnects only the WebRTC peer connection without re-establishing signaling.
4760
- * Closes the current PeerConnection and creates a new client-initiated offer
4761
- * through the existing WebSocket connection.
4762
- * @returns true if reconnection was initiated, false if signaling is not connected
4763
- */
4764
- reconnectPeer() {
4765
- if (!this.signallingSrv.isConnected()) {
4766
- Logger.error('[Reconnect] Cannot reconnect peer: Signaling not connected');
4767
- return false;
4768
- }
4769
- if (!this.cfg || Object.keys(this.cfg).length === 0) {
4770
- Logger.error('[Reconnect] Cannot reconnect peer: No config available');
4771
- return false;
4772
- }
4773
- Logger.info('[Reconnect] Initiating peer reconnection');
4774
- // Close only the PeerConnection, keep WebSocket alive
4775
- this.closePC();
4776
- // Create a new WebRTC offer using the stored config
4777
- void this.createWebRtcOffer();
4778
- return true;
4779
- }
4780
- setConfig(parOptions = {}) {
4781
- const urlParams = new URLSearchParams(window.location.search);
4782
- //**********************
4783
- //Config setup
4784
- //**********************
4785
- this.cfg = { ...parOptions.peerConnectionOptions };
4786
- this.forceTURN = urlParams.has('ForceTURN');
4787
- if (this.forceTURN) {
4788
- Logger.info('Forcing TURN usage by setting ICE Transport Policy in peer connection config.');
4789
- this.cfg.iceTransportPolicy = 'relay';
4790
- }
4791
- this.cfg.bundlePolicy = urlParams.has('ForceMaxBundle')
4792
- ? 'max-bundle'
4793
- : 'balanced';
4794
- this.cfg.iceCandidatePoolSize = 1;
4795
- // When ?useMic check for SSL or localhost
4796
- const isLocalhostConnection = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
4797
- const isHttpsConnection = location.protocol === 'https:';
4798
- if (this.useMic && !isLocalhostConnection && !isHttpsConnection) {
4799
- this.useMic = false;
4800
- Logger.error('Microphone access in the browser will not work if you are not on HTTPS or localhost. Disabling mic access.');
4801
- Logger.error("For testing you can enable HTTP microphone access Chrome by visiting chrome://flags/ and enabling 'unsafely-treat-insecure-origin-as-secure'");
4802
- }
4803
- this.setupPeerConnection(this.cfg);
4804
- }
4805
- handlePeerConnectionEvents(pcClient) {
4806
- fromEvent(pcClient, 'signalingstatechange')
4807
- .pipe(map$1(() => this.pcClient?.signalingState), takeUntil(this.disconnect$))
4808
- .subscribe((state) => {
4809
- Logger.info('[Signaling state change] |', state, '|');
4810
- this.commandTelemetryService.trackStopCommand('EXT-Signaling state change', true, { state, multi: true });
4811
- });
4812
- fromEvent(pcClient, 'iceconnectionstatechange')
4813
- .pipe(map$1(() => this.pcClient?.iceConnectionState), takeUntil(this.disconnect$))
4814
- .subscribe((state) => {
4815
- Logger.info('[Browser ICE connection] |', state, '|');
4816
- this.commandTelemetryService.trackStopCommand('EXT-Browser ICE connection', true, { state, multi: true });
4817
- });
4818
- fromEvent(pcClient, 'icegatheringstatechange')
4819
- .pipe(map$1(() => this.pcClient?.iceGatheringState), takeUntil(this.disconnect$))
4820
- .subscribe((state) => {
4821
- Logger.info('[Browser ICE gathering] |', state, '|');
4822
- this.commandTelemetryService.trackStopCommand('EXT-Browser ICE gathering', true, { state, multi: true });
4823
- });
4824
- fromEvent(pcClient, 'connectionstatechange')
4825
- .pipe(map$1(() => this.pcClient?.connectionState), takeUntil(this.disconnect$))
4826
- .subscribe((state) => {
4827
- Logger.info('[RTCPeerConnection state] |', state, '|');
4828
- });
4829
- fromEvent(pcClient, 'icecandidate')
4830
- .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$))
4831
- .subscribe((candidate) => this.signallingSrv.send({ type: 'iceCandidate', candidate }));
4832
- // When the server creates the DataChannel (server-offer flow),
4833
- // we receive it here via the 'datachannel' event
4834
- fromEvent(pcClient, 'datachannel')
4835
- .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$))
4836
- .subscribe((datachannel) => {
4837
- this.dcClient = this.setupDataChannelCallbacks(datachannel);
4838
- });
4839
- const onTrack$ = fromEvent(pcClient, 'track').pipe(takeUntil(this.disconnect$), filter((e) => !!e.track), tap$1((e) => Logger.info('Got track - ' +
4840
- e.track.kind +
4841
- ' id=' +
4842
- e.track.id +
4843
- ' readyState=' +
4844
- e.track.readyState)), share());
4845
- onTrack$
4846
- .pipe(filter((e) => e.track.kind === 'audio'), map$1((e) => e.streams[0]))
4847
- .subscribe((stream) => this.videoService.attachAudioStream(stream));
4848
- onTrack$
4849
- .pipe(filter((e) => e.track.kind === 'video'), map$1((e) => e.streams[0]), tap$1(() => Logger.info('Set video source from video track ontrack.')))
4850
- .subscribe((stream) => {
4851
- if (this.pcClient) {
4852
- this.videoService.attachVideoStream(stream, this.pcClient);
4853
- }
4854
- });
4855
- }
4856
- async setupTransceiversAsync(pc) {
4857
- const hasTransceivers = pc.getTransceivers().length > 0;
4858
- // Set up a transceiver for getting UE video
4859
- pc.addTransceiver('video', { direction: 'recvonly' });
4860
- // Set up a transceiver for sending mic audio to UE and receiving audio from UE
4861
- if (!this.useMic) {
4862
- pc.addTransceiver('audio', { direction: 'recvonly' });
4863
- }
4864
- else {
4865
- const audioSendOptions = this.useMic
4866
- ? {
4867
- autoGainControl: false,
4868
- channelCount: 1,
4869
- echoCancellation: false,
4870
- latency: 0,
4871
- noiseSuppression: false,
4872
- sampleRate: 48000,
4873
- sampleSize: 16,
4874
- volume: 1.0,
4875
- }
4876
- : false;
4877
- // Note using mic on android chrome requires SSL or chrome://flags/ "unsafely-treat-insecure-origin-as-secure"
4878
- const stream = await navigator.mediaDevices.getUserMedia({
4879
- video: false,
4880
- audio: audioSendOptions,
4881
- });
4882
- if (stream) {
4883
- if (hasTransceivers) {
4884
- for (const transceiver of pc.getTransceivers()) {
4885
- if (transceiver &&
4886
- transceiver.receiver &&
4887
- transceiver.receiver.track &&
4888
- transceiver.receiver.track.kind === 'audio') {
4889
- for (const track of stream.getTracks()) {
4890
- if (track.kind && track.kind === 'audio') {
4891
- await transceiver.sender.replaceTrack(track);
4892
- transceiver.direction = 'sendrecv';
4893
- }
4894
- }
4895
- }
4896
- }
4897
- }
4898
- else {
4899
- for (const track of stream.getTracks()) {
4900
- if (track.kind && track.kind === 'audio') {
4901
- pc.addTransceiver(track, { direction: 'sendrecv' });
4902
- }
4903
- }
4904
- }
4905
- }
4906
- else {
4907
- pc.addTransceiver('audio', { direction: 'recvonly' });
4908
- }
4909
- }
4910
- }
4911
- setupDataChannelCallbacks(datachannel) {
4912
- try {
4913
- Logger.log(`[DATACHANNEL] Setting up datachannel "${datachannel.label}" callbacks...`);
4914
- this.commandTelemetryService.trackStartCommand('EXT-DataChannel Connection');
4915
- // Inform browser we would like binary data as an ArrayBuffer (FF chooses Blob by default!)
4916
- datachannel.binaryType = 'arraybuffer';
4917
- fromEvent(datachannel, 'close')
4918
- .pipe(
4919
- // Only dispatch disconnect if not already reconnecting to avoid infinite loops
4920
- filter(() => !this.isReconnecting()), take(1))
4921
- .subscribe((e) => {
4922
- Logger.warn(`[DATACHANNEL] Data channel disconnected: ${datachannel.label}(${datachannel.id})`, e);
4923
- this.store.dispatch(disconnectStream({
4924
- reason: DisconnectReason.DataChannelClosed,
4925
- message: 'DataChannelClosed',
4926
- }));
4927
- });
4928
- fromEvent(datachannel, 'error')
4929
- .pipe(takeUntil(this.disconnect$))
4930
- .subscribe((e) => {
4931
- const message = `[DATACHANNEL] Data channel "${datachannel.label}" error. id:${datachannel.id}`;
4932
- Logger.error(message, e);
4933
- });
4934
- fromEvent(datachannel, 'open')
4935
- .pipe(tap$1(() => {
4936
- Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
4937
- }), takeUntil(this.disconnect$))
4938
- .subscribe(() => {
4939
- this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
4940
- this.onDataChannelConnected();
4941
- });
4942
- fromEvent(datachannel, 'message')
4943
- .pipe(
4944
- /* tap((e: MessageEvent) => {
4945
- const view = new Uint8Array(e.data);
4946
- Logger.info(
4947
- `[DATACHANNEL] Message received on "${datachannel.label}" id=${datachannel.id}, byteCode=${view[0]}, size=${view.byteLength}`,
4948
- );
4949
- }),*/
4950
- map$1((e) => e.data), takeUntil(this.disconnect$))
4951
- .subscribe((data) => this.onDataChannelMessage$.next(data));
4952
- return datachannel;
4953
- }
4954
- catch (e) {
4955
- Logger.warn('[DATACHANNEL] Datachannel setup caused an exception: ', e);
4956
- return null;
4957
- }
4958
- }
4959
- onDataChannelConnected() {
4960
- let statusMessage = 'Session connected, video stream starting.';
4961
- if (navigator.userAgent.includes('Firefox')) {
4962
- statusMessage +=
4963
- '\n If you are experiencing connection problems please try Google Chrome';
4964
- }
4965
- this.store.dispatch(dataChannelConnected({ statusMessage }));
4966
- }
4967
- /**
4968
- * Creates an SDP offer, applies SDP munging, sets it as a local description,
4969
- * and sends it to the server via signaling.
4970
- * Transceivers already configure media directions, so deprecated
4971
- * offerToReceive* options are not used.
4972
- */
4973
- async createOffer(pcClient) {
4974
- try {
4975
- const offer = await pcClient.createOffer();
4976
- offer.sdp = this.mungeSDP(offer.sdp ?? '');
4977
- await pcClient.setLocalDescription(offer);
4978
- this.signallingSrv.send(offer);
4979
- }
4980
- catch {
4981
- Logger.error("Couldn't create offer");
4982
- }
4983
- }
4984
- /**
4985
- * Creates an SDP answer, applies SDP munging, sets it as a local description,
4986
- * and sends it to the server via signaling.
4987
- */
4988
- async createAnswer(pcClient) {
4989
- try {
4990
- const answer = await pcClient.createAnswer();
4991
- answer.sdp = this.mungeSDP(answer.sdp ?? '');
4992
- await pcClient.setLocalDescription(answer);
4993
- this.signallingSrv.send(answer);
4994
- }
4995
- catch (e) {
4996
- Logger.error("Couldn't create answer", e);
4997
- }
4998
- }
4999
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: WebRtcPlayerService2, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5000
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: WebRtcPlayerService2 }); }
5001
- }
5002
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: WebRtcPlayerService2, decorators: [{
5003
- type: Injectable
5004
- }], ctorParameters: () => [] });
5005
-
5006
- const ReceivedMimeTypes = {
5007
- ApplicationJson: 'application/json',
5008
- };
5009
-
5010
- class FileHandlerService {
4610
+ const ReceivedMimeTypes = {
4611
+ ApplicationJson: 'application/json',
4612
+ };
4613
+
4614
+ class FileHandlerService {
5011
4615
  constructor() {
5012
4616
  this.fileService = inject(FileReceiverService);
5013
4617
  this.fileHandlers = {
@@ -5212,8 +4816,14 @@ class FpsMonitorService {
5212
4816
  this.videoService = inject(VideoService);
5213
4817
  this.store = inject(Store);
5214
4818
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
5215
- /** Discrete FPS targets the controller can switch between (ascending order). */
5216
- this.FPS_STEPS = [30, 40, 50, 60];
4819
+ /** Discrete FPS targets the controller can switch between (ascending order). Last 0 is unlimited framerate indicator*/
4820
+ this.FPS_STEPS = [30, 40, 50, 0];
4821
+ /**
4822
+ * Reference FPS used to compute the FPS ratio when `currentFpsTarget` is 0
4823
+ * (unlimited). Keeps adaptive logic operational at the unlimited step:
4824
+ * if the stream drops well below this value, fuzzy rules can still fire.
4825
+ */
4826
+ this.UNLIMITED_REFERENCE_FPS = 60;
5217
4827
  /** Reference ceiling for bitrate (bits/sec) used to normalize a bitrate load to 0..1. */
5218
4828
  this.MAX_BITRATE = 20_000_000;
5219
4829
  /**
@@ -5311,7 +4921,8 @@ class FpsMonitorService {
5311
4921
  * - `quality` = mapQpToQuality(avgQP) → 'lime' | 'orange' | 'red'
5312
4922
  */
5313
4923
  evaluateDecision(avg, currentTarget) {
5314
- const fpsRatio = currentTarget > 0 ? avg.fps / currentTarget : 1;
4924
+ const referenceTarget = currentTarget > 0 ? currentTarget : this.UNLIMITED_REFERENCE_FPS;
4925
+ const fpsRatio = avg.fps / referenceTarget;
5315
4926
  const bitrateLoad = avg.bitrate / this.MAX_BITRATE;
5316
4927
  const fpsLow = this.membershipFpsLow(fpsRatio);
5317
4928
  const fpsOk = this.membershipFpsOk(fpsRatio);
@@ -5336,10 +4947,14 @@ class FpsMonitorService {
5336
4947
  }
5337
4948
  // Rule 4: FPS meets/exceeds target (ratio >= ~0.95), quality is 'lime'
5338
4949
  // (QP <= 26), and bitrate is below 50% of ceiling — safe to step up.
4950
+ // Note: the last FPS_STEPS value (0) means "no limit", so compare by
4951
+ // position in FPS_STEPS rather than numerically.
4952
+ const currentIndex = this.FPS_STEPS.indexOf(this.currentFpsTarget);
5339
4953
  if (fpsHigh > 0.5 &&
5340
4954
  quality === 'lime' &&
5341
4955
  bitrateHigh < 0.5 &&
5342
- this.currentFpsTarget < this.FPS_STEPS[this.FPS_STEPS.length - 1]) {
4956
+ currentIndex !== -1 &&
4957
+ currentIndex < this.FPS_STEPS.length - 1) {
5343
4958
  return 'increase';
5344
4959
  }
5345
4960
  return 'hold';
@@ -6085,7 +5700,6 @@ class UnrealSceneComponent {
6085
5700
  this.changeMouseOverScene.emit(false);
6086
5701
  }
6087
5702
  constructor() {
6088
- this.unrealConfig = inject(UNREAL_CONFIG);
6089
5703
  this.isStudio = input(false, ...(ngDevMode ? [{ debugName: "isStudio" }] : /* istanbul ignore next */ []));
6090
5704
  this.useContainerAsSizeProvider = input(true, ...(ngDevMode ? [{ debugName: "useContainerAsSizeProvider" }] : /* istanbul ignore next */ []));
6091
5705
  this.studioResolutionSize = input({
@@ -6188,16 +5802,7 @@ class UnrealSceneComponent {
6188
5802
  containerHeight = this.videoElement()?.nativeElement.offsetHeight || 2;
6189
5803
  }
6190
5804
  const pixelRatio = this.pixelRatio;
6191
- if (this.unrealConfig.optimizedResize) {
6192
- // New Approach
6193
- return getResizeValuesUniversal({
6194
- pixelRatio,
6195
- containerWidth,
6196
- containerHeight,
6197
- maxResolution: this.maxResolution,
6198
- });
6199
- }
6200
- return getResizeValuesUniversalOld({
5805
+ return getResizeValuesUniversal({
6201
5806
  pixelRatio,
6202
5807
  containerWidth,
6203
5808
  containerHeight,
@@ -6206,17 +5811,7 @@ class UnrealSceneComponent {
6206
5811
  }
6207
5812
  getResizeStudioValues() {
6208
5813
  const videoElement = this.videoElement()?.nativeElement;
6209
- if (this.unrealConfig.optimizedResize) {
6210
- return getResizeStudioValues({
6211
- pixelRatio: this.pixelRatio,
6212
- containerWidth: videoElement?.offsetWidth || 2,
6213
- containerHeight: videoElement?.offsetHeight || 2,
6214
- studioWidth: this.width,
6215
- studioHeight: this.height,
6216
- maxResolution: this.maxResolution,
6217
- });
6218
- }
6219
- return getResizeStudioValuesOld({
5814
+ return getResizeStudioValues({
6220
5815
  pixelRatio: this.pixelRatio,
6221
5816
  containerWidth: videoElement?.offsetWidth || 2,
6222
5817
  containerHeight: videoElement?.offsetHeight || 2,
@@ -6464,15 +6059,7 @@ function provideUnrealModule() {
6464
6059
  SignallingService,
6465
6060
  StreamStatusTelemetryService,
6466
6061
  FpsMonitorService,
6467
- {
6468
- provide: WebRtcPlayerService,
6469
- useFactory: () => {
6470
- const config = inject(UNREAL_CONFIG, { optional: true });
6471
- return config?.newWebRtcConnector
6472
- ? new WebRtcPlayerService2()
6473
- : new WebRtcPlayerService();
6474
- },
6475
- },
6062
+ WebRtcPlayerService,
6476
6063
  provideEnvironmentInitializer(() => {
6477
6064
  inject(AggregatorService);
6478
6065
  inject(InputService);
@@ -6490,5 +6077,5 @@ function provideUnrealModule() {
6490
6077
  * Generated bundle index. Do not edit.
6491
6078
  */
6492
6079
 
6493
- export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, 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, getResizeStudioValues, getResizeStudioValuesOld, getResizeValuesUniversal, getResizeValuesUniversalOld, getRtcErrorMessage, iceConnectionFailed, initSignalling, keepMaxUntilReset, makeEven, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setDataChannelConnected, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setKalmanSettings, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setViewportNotReady, setViewportReady, showUnrealErrorMessage, smoothTransition, startStream, trackMixpanelEvent, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
6080
+ export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, CommandsEffects, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, changeLowBandwidth, changeStatusMainVideoOnScene, changeStreamResolutionAction, changeStreamResolutionSuccessAction, commandCompleted, commandStarted, commandsFeature, commandsReducer, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeStudioValuesOld, getResizeValuesUniversal, getResizeValuesUniversalOld, getRtcErrorMessage, iceConnectionFailed, initSignalling, keepMaxUntilReset, makeEven, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetWarnTimeout, saveAnalyticsEvent, selectClientAndViewIds, selectCommandProgress, selectCommandsInProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setDataChannelConnected, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setKalmanSettings, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setViewportNotReady, setViewportReady, showUnrealErrorMessage, smoothTransition, startStream, trackMixpanelEvent, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
6494
6081
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map