@3dsource/angular-unreal-module 0.0.101-dev.1 → 0.0.101-dev.3
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.
|
@@ -3581,14 +3581,6 @@ class InputService extends SubService {
|
|
|
3581
3581
|
};
|
|
3582
3582
|
}
|
|
3583
3583
|
};
|
|
3584
|
-
this.unQuantizeAndDenormalizeUnsigned = (x, y) => {
|
|
3585
|
-
const normalizedX = x / 65536;
|
|
3586
|
-
const normalizedY = y / 65536;
|
|
3587
|
-
return {
|
|
3588
|
-
x: normalizedX * videoElement.clientWidth,
|
|
3589
|
-
y: normalizedY * videoElement.clientHeight,
|
|
3590
|
-
};
|
|
3591
|
-
};
|
|
3592
3584
|
// Signed.
|
|
3593
3585
|
this.normalizeAndQuantizeSigned = (x, y) => {
|
|
3594
3586
|
const normalizedX = x / (0.5 * videoElement.clientWidth);
|
|
@@ -3724,6 +3716,7 @@ class InputService extends SubService {
|
|
|
3724
3716
|
}
|
|
3725
3717
|
emitMouseDown(button, x, y) {
|
|
3726
3718
|
const coord = this.normalizeAndQuantizeUnsigned(x, y);
|
|
3719
|
+
console.log(button, coord);
|
|
3727
3720
|
const Data = new DataView(new ArrayBuffer(6));
|
|
3728
3721
|
Data.setUint8(0, MessageType.MouseDown);
|
|
3729
3722
|
Data.setUint8(1, button);
|
|
@@ -5601,6 +5594,555 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
|
|
|
5601
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"] }]
|
|
5602
5595
|
}] });
|
|
5603
5596
|
|
|
5597
|
+
/*
|
|
5598
|
+
* Copyright (c) 2026.
|
|
5599
|
+
* 3DSource.com. Sergii Karanda steve@3dsource.com. All Rights Reserved.
|
|
5600
|
+
*/
|
|
5601
|
+
class WebRtcPlayerService2 extends SubService {
|
|
5602
|
+
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());
|
|
5620
|
+
}
|
|
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
|
+
}
|
|
5647
|
+
});
|
|
5648
|
+
}
|
|
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);
|
|
5670
|
+
}
|
|
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
|
+
}));
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
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();
|
|
5689
|
+
}
|
|
5690
|
+
catch (e) {
|
|
5691
|
+
Logger.error('Failed to set remote description from answer', e);
|
|
5692
|
+
}
|
|
5693
|
+
}
|
|
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;
|
|
5700
|
+
}
|
|
5701
|
+
/**
|
|
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.
|
|
5705
|
+
*/
|
|
5706
|
+
async createWebRtcOffer() {
|
|
5707
|
+
Logger.info('Creating client-initiated offer');
|
|
5708
|
+
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);
|
|
5718
|
+
}
|
|
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
|
+
}));
|
|
5725
|
+
}
|
|
5726
|
+
}
|
|
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();
|
|
5742
|
+
});
|
|
5743
|
+
}
|
|
5744
|
+
createDataChannel(pc, label, options) {
|
|
5745
|
+
const datachannel = pc.createDataChannel(label, options);
|
|
5746
|
+
return this.setupDataChannelCallbacks(datachannel);
|
|
5747
|
+
}
|
|
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);
|
|
5765
|
+
}
|
|
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
|
+
}
|
|
5811
|
+
}
|
|
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;';
|
|
5838
|
+
}
|
|
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;
|
|
5846
|
+
}
|
|
5847
|
+
/**
|
|
5848
|
+
* Closes existing PeerConnection and DataChannel, resets negotiation state.
|
|
5849
|
+
*/
|
|
5850
|
+
closePC() {
|
|
5851
|
+
if (this.pcClient) {
|
|
5852
|
+
Logger.warn('Closing existing PeerConnection');
|
|
5853
|
+
}
|
|
5854
|
+
this.pcClient?.close();
|
|
5855
|
+
this.pcClient = null;
|
|
5856
|
+
if (this.dcClient) {
|
|
5857
|
+
Logger.warn('Closing existing DataChannel');
|
|
5858
|
+
}
|
|
5859
|
+
this.dcClient?.close();
|
|
5860
|
+
this.dcClient = null;
|
|
5861
|
+
this.remoteDescriptionSet = false;
|
|
5862
|
+
this.iceCandidateBuffer.length = 0;
|
|
5863
|
+
}
|
|
5864
|
+
canSend() {
|
|
5865
|
+
return this.dcClient && this.dcClient.readyState === 'open';
|
|
5866
|
+
}
|
|
5867
|
+
send(data) {
|
|
5868
|
+
if (this.canSend()) {
|
|
5869
|
+
this.dcClient?.send(data);
|
|
5870
|
+
}
|
|
5871
|
+
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();
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
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;
|
|
5890
|
+
}
|
|
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 }));
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
/**
|
|
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
|
|
5903
|
+
*/
|
|
5904
|
+
reconnectPeer() {
|
|
5905
|
+
if (!this.signallingSrv.isConnected()) {
|
|
5906
|
+
Logger.error('[Reconnect] Cannot reconnect peer: Signaling not connected');
|
|
5907
|
+
return false;
|
|
5908
|
+
}
|
|
5909
|
+
if (!this.cfg || Object.keys(this.cfg).length === 0) {
|
|
5910
|
+
Logger.error('[Reconnect] Cannot reconnect peer: No config available');
|
|
5911
|
+
return false;
|
|
5912
|
+
}
|
|
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';
|
|
5930
|
+
}
|
|
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'");
|
|
5942
|
+
}
|
|
5943
|
+
this.setupPeerConnection(this.cfg);
|
|
5944
|
+
}
|
|
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);
|
|
5978
|
+
});
|
|
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
|
+
}
|
|
5994
|
+
});
|
|
5995
|
+
}
|
|
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' });
|
|
6048
|
+
}
|
|
6049
|
+
}
|
|
6050
|
+
}
|
|
6051
|
+
setupDataChannelCallbacks(datachannel) {
|
|
6052
|
+
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;
|
|
6093
|
+
}
|
|
6094
|
+
catch (e) {
|
|
6095
|
+
Logger.warn('[DATACHANNEL] Datachannel setup caused an exception: ', e);
|
|
6096
|
+
return null;
|
|
6097
|
+
}
|
|
6098
|
+
}
|
|
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 }));
|
|
6106
|
+
}
|
|
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
|
+
}
|
|
6123
|
+
}
|
|
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);
|
|
6134
|
+
}
|
|
6135
|
+
catch (e) {
|
|
6136
|
+
Logger.error("Couldn't create answer", e);
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
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 }); }
|
|
6141
|
+
}
|
|
6142
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: WebRtcPlayerService2, decorators: [{
|
|
6143
|
+
type: Injectable
|
|
6144
|
+
}], ctorParameters: () => [] });
|
|
6145
|
+
|
|
5604
6146
|
function provideAngularUnrealModule(config) {
|
|
5605
6147
|
return makeEnvironmentProviders([
|
|
5606
6148
|
provideState(commandsFeature),
|
|
@@ -5609,7 +6151,12 @@ function provideAngularUnrealModule(config) {
|
|
|
5609
6151
|
ConsoleExtensionsService,
|
|
5610
6152
|
InputService,
|
|
5611
6153
|
VideoService,
|
|
5612
|
-
|
|
6154
|
+
{
|
|
6155
|
+
provide: WebRtcPlayerService,
|
|
6156
|
+
useClass: config?.newWebRtcConnector
|
|
6157
|
+
? WebRtcPlayerService2
|
|
6158
|
+
: WebRtcPlayerService,
|
|
6159
|
+
},
|
|
5613
6160
|
RegionsPingService,
|
|
5614
6161
|
FileReceiverService,
|
|
5615
6162
|
FileHandlerService,
|