@gjsify/webrtc 0.3.21 → 0.4.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.
- package/lib/esm/_virtual/_rolldown/runtime.js +1 -0
- package/lib/esm/get-user-media.js +1 -1
- package/lib/esm/gst-enum-maps.js +1 -1
- package/lib/esm/gst-init.js +1 -1
- package/lib/esm/gst-stats-parser.js +1 -1
- package/lib/esm/gst-utils.js +1 -1
- package/lib/esm/internal/gst-types.js +1 -0
- package/lib/esm/media-device-info.js +1 -1
- package/lib/esm/media-devices.js +1 -1
- package/lib/esm/media-stream-track.js +1 -1
- package/lib/esm/media-stream.js +1 -1
- package/lib/esm/rtc-certificate.js +1 -1
- package/lib/esm/rtc-data-channel.js +1 -1
- package/lib/esm/rtc-dtls-transport.js +1 -1
- package/lib/esm/rtc-dtmf-sender.js +1 -1
- package/lib/esm/rtc-error.js +1 -1
- package/lib/esm/rtc-events.js +1 -1
- package/lib/esm/rtc-ice-candidate.js +1 -1
- package/lib/esm/rtc-ice-transport.js +1 -1
- package/lib/esm/rtc-peer-connection.js +1 -1
- package/lib/esm/rtc-rtp-receiver.js +1 -1
- package/lib/esm/rtc-rtp-sender.js +1 -1
- package/lib/esm/rtc-rtp-transceiver.js +1 -1
- package/lib/esm/rtc-sctp-transport.js +1 -1
- package/lib/esm/rtc-session-description.js +1 -1
- package/lib/esm/rtc-stats-report.js +1 -1
- package/lib/esm/rtc-track-event.js +1 -1
- package/lib/esm/rtp-capabilities.js +1 -1
- package/lib/esm/tee-multiplexer.js +1 -1
- package/lib/esm/wpt-helpers.js +1 -1
- package/lib/types/gst-enum-maps.d.ts +2 -1
- package/lib/types/internal/gst-types.d.ts +83 -0
- package/lib/types/rtc-rtp-sender.d.ts +3 -2
- package/package.json +73 -70
- package/src/get-user-media.ts +0 -131
- package/src/gst-enum-maps.ts +0 -125
- package/src/gst-init.ts +0 -49
- package/src/gst-stats-parser.ts +0 -137
- package/src/gst-utils.ts +0 -41
- package/src/index.ts +0 -104
- package/src/media-device-info.ts +0 -33
- package/src/media-devices.ts +0 -191
- package/src/media-stream-track.ts +0 -159
- package/src/media-stream.ts +0 -96
- package/src/register/data-channel.ts +0 -11
- package/src/register/error.ts +0 -11
- package/src/register/media-devices.ts +0 -10
- package/src/register/media.ts +0 -15
- package/src/register/peer-connection.ts +0 -20
- package/src/register.spec.ts +0 -55
- package/src/register.ts +0 -10
- package/src/rtc-certificate.ts +0 -110
- package/src/rtc-data-channel.ts +0 -283
- package/src/rtc-dtls-transport.ts +0 -48
- package/src/rtc-dtmf-sender.ts +0 -146
- package/src/rtc-error.ts +0 -49
- package/src/rtc-events.ts +0 -64
- package/src/rtc-ice-candidate.ts +0 -115
- package/src/rtc-ice-transport.ts +0 -104
- package/src/rtc-peer-connection.ts +0 -1023
- package/src/rtc-rtp-receiver.ts +0 -122
- package/src/rtc-rtp-sender.ts +0 -444
- package/src/rtc-rtp-transceiver.ts +0 -127
- package/src/rtc-sctp-transport.ts +0 -48
- package/src/rtc-session-description.ts +0 -64
- package/src/rtc-stats-report.ts +0 -39
- package/src/rtc-track-event.ts +0 -45
- package/src/rtp-capabilities.ts +0 -48
- package/src/tee-multiplexer.ts +0 -75
- package/src/test.mts +0 -11
- package/src/webrtc.spec.ts +0 -1186
- package/src/wpt-helpers.ts +0 -156
- package/src/wpt-media.spec.ts +0 -1154
- package/src/wpt.spec.ts +0 -1136
- package/tsconfig.json +0 -36
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,1023 +0,0 @@
|
|
|
1
|
-
// RTCPeerConnection — W3C WebRTC peer connection backed by GStreamer webrtcbin.
|
|
2
|
-
//
|
|
3
|
-
// Reference: refs/node-gst-webrtc/src/webrtc/RTCPeerConnection.ts (ISC)
|
|
4
|
-
// Adapted from node-gtk to GJS. Phase 1: Data Channel. Phase 2: Media API
|
|
5
|
-
// surface (addTransceiver, getSenders/getReceivers/getTransceivers, RTCTrackEvent).
|
|
6
|
-
|
|
7
|
-
import GLib from 'gi://GLib?version=2.0';
|
|
8
|
-
import GObject from 'gi://GObject?version=2.0';
|
|
9
|
-
import GstWebRTC from 'gi://GstWebRTC?version=1.0';
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
WebrtcbinBridge,
|
|
13
|
-
type WebrtcbinBridge as WebrtcbinBridgeType,
|
|
14
|
-
type DataChannelBridge as DataChannelBridgeType,
|
|
15
|
-
} from '@gjsify/webrtc-native';
|
|
16
|
-
import { ensureWebrtcbinAvailable, Gst } from './gst-init.js';
|
|
17
|
-
import { withGstPromise } from './gst-utils.js';
|
|
18
|
-
import {
|
|
19
|
-
gstToSignalingState,
|
|
20
|
-
gstToConnectionState,
|
|
21
|
-
gstToIceConnectionState,
|
|
22
|
-
gstToIceGatheringState,
|
|
23
|
-
w3cDirectionToGst,
|
|
24
|
-
} from './gst-enum-maps.js';
|
|
25
|
-
import { DOMException } from '@gjsify/dom-exception';
|
|
26
|
-
import { RTCSessionDescription, type RTCSessionDescriptionInit } from './rtc-session-description.js';
|
|
27
|
-
import { RTCIceCandidate, type RTCIceCandidateInit } from './rtc-ice-candidate.js';
|
|
28
|
-
import { RTCDataChannel } from './rtc-data-channel.js';
|
|
29
|
-
import { RTCPeerConnectionIceEvent, RTCDataChannelEvent } from './rtc-events.js';
|
|
30
|
-
import { RTCRtpSender, type RTCRtpTransceiverDirection } from './rtc-rtp-sender.js';
|
|
31
|
-
import { RTCRtpReceiver } from './rtc-rtp-receiver.js';
|
|
32
|
-
import { RTCRtpTransceiver } from './rtc-rtp-transceiver.js';
|
|
33
|
-
import { MediaStream } from './media-stream.js';
|
|
34
|
-
import { MediaStreamTrack } from './media-stream-track.js';
|
|
35
|
-
import { RTCTrackEvent } from './rtc-track-event.js';
|
|
36
|
-
import { parseGstStats, filterStatsByTrackId } from './gst-stats-parser.js';
|
|
37
|
-
import type { RTCStatsReport } from './rtc-stats-report.js';
|
|
38
|
-
import { RTCIceTransport } from './rtc-ice-transport.js';
|
|
39
|
-
import { RTCDtlsTransport } from './rtc-dtls-transport.js';
|
|
40
|
-
import { RTCSctpTransport } from './rtc-sctp-transport.js';
|
|
41
|
-
import { RTCCertificate, generateCertificate, type AlgorithmIdentifier } from './rtc-certificate.js';
|
|
42
|
-
|
|
43
|
-
export type RTCSignalingState =
|
|
44
|
-
| 'stable' | 'closed'
|
|
45
|
-
| 'have-local-offer' | 'have-remote-offer'
|
|
46
|
-
| 'have-local-pranswer' | 'have-remote-pranswer';
|
|
47
|
-
export type RTCPeerConnectionState =
|
|
48
|
-
| 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
|
49
|
-
export type RTCIceConnectionState =
|
|
50
|
-
| 'new' | 'checking' | 'connected' | 'completed' | 'failed' | 'disconnected' | 'closed';
|
|
51
|
-
export type RTCIceGatheringState = 'new' | 'gathering' | 'complete';
|
|
52
|
-
export type RTCIceTransportPolicy = 'all' | 'relay';
|
|
53
|
-
export type RTCBundlePolicy = 'balanced' | 'max-compat' | 'max-bundle';
|
|
54
|
-
export type RTCRtcpMuxPolicy = 'require';
|
|
55
|
-
|
|
56
|
-
export interface RTCIceServer {
|
|
57
|
-
urls: string | string[];
|
|
58
|
-
username?: string;
|
|
59
|
-
credential?: string;
|
|
60
|
-
credentialType?: 'password';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface RTCConfiguration {
|
|
64
|
-
iceServers?: RTCIceServer[];
|
|
65
|
-
iceTransportPolicy?: RTCIceTransportPolicy;
|
|
66
|
-
bundlePolicy?: RTCBundlePolicy;
|
|
67
|
-
rtcpMuxPolicy?: RTCRtcpMuxPolicy;
|
|
68
|
-
peerIdentity?: string;
|
|
69
|
-
certificates?: unknown[];
|
|
70
|
-
iceCandidatePoolSize?: number;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface RTCOfferOptions {
|
|
74
|
-
offerToReceiveAudio?: boolean;
|
|
75
|
-
offerToReceiveVideo?: boolean;
|
|
76
|
-
iceRestart?: boolean;
|
|
77
|
-
}
|
|
78
|
-
export interface RTCAnswerOptions {}
|
|
79
|
-
|
|
80
|
-
export interface RTCDataChannelInit {
|
|
81
|
-
ordered?: boolean;
|
|
82
|
-
maxPacketLifeTime?: number;
|
|
83
|
-
maxRetransmits?: number;
|
|
84
|
-
protocol?: string;
|
|
85
|
-
negotiated?: boolean;
|
|
86
|
-
id?: number;
|
|
87
|
-
priority?: 'very-low' | 'low' | 'medium' | 'high';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type EventHandler<E extends Event = Event> =
|
|
91
|
-
((this: RTCPeerConnection, ev: E) => any) | null;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Web-IDL `[EnforceRange] unsigned short` coercion. Coerces via ToNumber,
|
|
95
|
-
* rejects values that can't be represented as an unsigned short (0..65535).
|
|
96
|
-
* Matches Web-IDL §3.2.4.10: reject NaN, ±Infinity, and integers outside
|
|
97
|
-
* the range; "100" → 100; fractional values are truncated.
|
|
98
|
-
*
|
|
99
|
-
* Reference: refs/wpt/webrtc/RTCDataChannelInit-{maxPacketLifeTime,maxRetransmits}-enforce-range.html
|
|
100
|
-
*/
|
|
101
|
-
function coerceUnsignedShort(name: string, raw: unknown): number {
|
|
102
|
-
const n = Number(raw);
|
|
103
|
-
if (!Number.isFinite(n)) {
|
|
104
|
-
throw new TypeError(`createDataChannel: ${name} must be a finite number, got ${String(raw)}`);
|
|
105
|
-
}
|
|
106
|
-
const truncated = Math.trunc(n);
|
|
107
|
-
if (truncated < 0 || truncated > 65535) {
|
|
108
|
-
throw new TypeError(`createDataChannel: ${name}=${truncated} is outside the [0, 65535] range`);
|
|
109
|
-
}
|
|
110
|
-
return truncated;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
export interface RTCRtpTransceiverInit {
|
|
115
|
-
direction?: RTCRtpTransceiverDirection;
|
|
116
|
-
streams?: MediaStream[];
|
|
117
|
-
sendEncodings?: Array<{ rid?: string; active?: boolean; maxBitrate?: number; scaleResolutionDownBy?: number }>;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let globalCounter = 0;
|
|
122
|
-
|
|
123
|
-
export class RTCPeerConnection extends EventTarget {
|
|
124
|
-
private _pipeline: Gst.Pipeline;
|
|
125
|
-
private _webrtcbin: Gst.Element;
|
|
126
|
-
private _bridge: WebrtcbinBridgeType;
|
|
127
|
-
private _conf: RTCConfiguration;
|
|
128
|
-
private _closed = false;
|
|
129
|
-
private _iceRestartNeeded = false;
|
|
130
|
-
private _hasNegotiated = false;
|
|
131
|
-
private _dataChannels = new Map<unknown, RTCDataChannel>();
|
|
132
|
-
private _transceivers = new Map<unknown, RTCRtpTransceiver>();
|
|
133
|
-
private _senders: RTCRtpSender[] = [];
|
|
134
|
-
private _receivers: RTCRtpReceiver[] = [];
|
|
135
|
-
private _iceTransport: RTCIceTransport | null = null;
|
|
136
|
-
private _dtlsTransport: RTCDtlsTransport | null = null;
|
|
137
|
-
private _sctpTransport: RTCSctpTransport | null = null;
|
|
138
|
-
readonly canTrickleIceCandidates: boolean = true;
|
|
139
|
-
|
|
140
|
-
constructor(configuration?: RTCConfiguration) {
|
|
141
|
-
super();
|
|
142
|
-
ensureWebrtcbinAvailable();
|
|
143
|
-
|
|
144
|
-
const [major, minor] = Gst.version();
|
|
145
|
-
if (major < 1 || (major === 1 && minor < 20)) {
|
|
146
|
-
throw new DOMException(
|
|
147
|
-
`@gjsify/webrtc requires GStreamer >= 1.20 (you have ${major}.${minor}). webrtcbin is only stable from 1.20 onward.`,
|
|
148
|
-
'NotSupportedError',
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const id = ++globalCounter;
|
|
153
|
-
this._pipeline = new Gst.Pipeline({ name: `gjsify-webrtc-pipeline-${id}` });
|
|
154
|
-
const bin = Gst.ElementFactory.make('webrtcbin', `gjsify-webrtcbin-${id}`);
|
|
155
|
-
if (!bin) {
|
|
156
|
-
throw new Error('Failed to create webrtcbin element');
|
|
157
|
-
}
|
|
158
|
-
this._webrtcbin = bin;
|
|
159
|
-
this._conf = { ...configuration };
|
|
160
|
-
|
|
161
|
-
// Validate certificates — expired certs must be rejected
|
|
162
|
-
if (configuration?.certificates) {
|
|
163
|
-
for (const cert of configuration.certificates) {
|
|
164
|
-
if (cert instanceof RTCCertificate && cert.expires <= Date.now()) {
|
|
165
|
-
throw new DOMException(
|
|
166
|
-
'RTCPeerConnection: one of the provided certificates has expired',
|
|
167
|
-
'InvalidAccessError',
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
this._applyIceServers(configuration?.iceServers ?? []);
|
|
174
|
-
this._applyIceTransportPolicy(configuration?.iceTransportPolicy);
|
|
175
|
-
this._applyBundlePolicy(configuration?.bundlePolicy);
|
|
176
|
-
|
|
177
|
-
this._pipeline.add(this._webrtcbin);
|
|
178
|
-
|
|
179
|
-
// Connect via @gjsify/webrtc-native's WebrtcbinBridge — webrtcbin fires
|
|
180
|
-
// its signals from the streaming thread, GJS would block direct JS
|
|
181
|
-
// callbacks. The bridge hops to the main context on the C side.
|
|
182
|
-
this._bridge = new (WebrtcbinBridge as any)({ bin: this._webrtcbin });
|
|
183
|
-
this._bridge.connect('negotiation-needed', () => this._handleNegotiationNeeded());
|
|
184
|
-
this._bridge.connect('icecandidate', (_b, mlineIndex, candidate) =>
|
|
185
|
-
this._handleIceCandidate(mlineIndex, candidate));
|
|
186
|
-
this._bridge.connect('datachannel', (_b, channelBridge) =>
|
|
187
|
-
this._handleDataChannel(channelBridge));
|
|
188
|
-
this._bridge.connect('new-transceiver', (_b, gstTrans) =>
|
|
189
|
-
this._handleNewTransceiver(gstTrans));
|
|
190
|
-
this._bridge.connect('pad-added', (_b, pad) =>
|
|
191
|
-
this._handlePadAdded(pad));
|
|
192
|
-
this._bridge.connect('connection-state-changed', () =>
|
|
193
|
-
this._dispatchStateChange('connectionstatechange'));
|
|
194
|
-
this._bridge.connect('ice-connection-state-changed', () =>
|
|
195
|
-
this._dispatchStateChange('iceconnectionstatechange'));
|
|
196
|
-
this._bridge.connect('ice-gathering-state-changed', () =>
|
|
197
|
-
this._dispatchStateChange('icegatheringstatechange'));
|
|
198
|
-
this._bridge.connect('signaling-state-changed', () =>
|
|
199
|
-
this._dispatchStateChange('signalingstatechange'));
|
|
200
|
-
|
|
201
|
-
// webrtcbin needs PLAYING to exit its `is_closed` state before it accepts
|
|
202
|
-
// createDataChannel/create-offer etc. (see GStreamer webrtcbin source).
|
|
203
|
-
this._pipeline.set_state(Gst.State.PLAYING);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ---- ICE server / policy config ---------------------------------------
|
|
207
|
-
|
|
208
|
-
private _applyIceServers(iceServers: RTCIceServer[]): void {
|
|
209
|
-
let stunSet = false;
|
|
210
|
-
for (const server of iceServers) {
|
|
211
|
-
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
212
|
-
if (urls.length === 0) {
|
|
213
|
-
throw new SyntaxError('RTCIceServer.urls must not be empty');
|
|
214
|
-
}
|
|
215
|
-
for (const url of urls) {
|
|
216
|
-
if (typeof url !== 'string' || url.length === 0) {
|
|
217
|
-
throw new TypeError('RTCIceServer.urls entries must be non-empty strings');
|
|
218
|
-
}
|
|
219
|
-
const colonIdx = url.indexOf(':');
|
|
220
|
-
if (colonIdx < 0) {
|
|
221
|
-
throw new TypeError(`Invalid ICE server URL "${url}"`);
|
|
222
|
-
}
|
|
223
|
-
const proto = url.slice(0, colonIdx + 1);
|
|
224
|
-
const hostPort = url.slice(colonIdx + 1);
|
|
225
|
-
|
|
226
|
-
if (proto === 'stun:' || proto === 'stuns:') {
|
|
227
|
-
if (stunSet) continue; // webrtcbin supports only one STUN server
|
|
228
|
-
(this._webrtcbin as any).stun_server = `${proto}//${hostPort}`;
|
|
229
|
-
stunSet = true;
|
|
230
|
-
} else if (proto === 'turn:' || proto === 'turns:') {
|
|
231
|
-
if (typeof server.username !== 'string' || typeof server.credential !== 'string') {
|
|
232
|
-
throw new TypeError(`TURN server credential for ${url} missing`);
|
|
233
|
-
}
|
|
234
|
-
const encUser = encodeURIComponent(server.username);
|
|
235
|
-
const encCred = encodeURIComponent(server.credential);
|
|
236
|
-
const turnUrl = `${proto}//${encUser}:${encCred}@${hostPort}`;
|
|
237
|
-
try {
|
|
238
|
-
this._webrtcbin.emit('add-turn-server', turnUrl);
|
|
239
|
-
} catch {
|
|
240
|
-
(this._webrtcbin as any).turn_server = turnUrl;
|
|
241
|
-
}
|
|
242
|
-
} else {
|
|
243
|
-
throw new TypeError(`Unsupported ICE server protocol "${proto}"`);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private _applyIceTransportPolicy(policy?: RTCIceTransportPolicy): void {
|
|
250
|
-
if (!policy) return;
|
|
251
|
-
const gstPolicy = policy === 'relay'
|
|
252
|
-
? GstWebRTC.WebRTCICETransportPolicy.RELAY
|
|
253
|
-
: GstWebRTC.WebRTCICETransportPolicy.ALL;
|
|
254
|
-
try { (this._webrtcbin as any).ice_transport_policy = gstPolicy; } catch { /* ignore */ }
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private _applyBundlePolicy(policy?: RTCBundlePolicy): void {
|
|
258
|
-
if (!policy) return;
|
|
259
|
-
let gstPolicy: number;
|
|
260
|
-
switch (policy) {
|
|
261
|
-
case 'balanced': gstPolicy = GstWebRTC.WebRTCBundlePolicy.BALANCED; break;
|
|
262
|
-
case 'max-compat': gstPolicy = GstWebRTC.WebRTCBundlePolicy.MAX_COMPAT; break;
|
|
263
|
-
case 'max-bundle': gstPolicy = GstWebRTC.WebRTCBundlePolicy.MAX_BUNDLE; break;
|
|
264
|
-
default: return;
|
|
265
|
-
}
|
|
266
|
-
try { (this._webrtcbin as any).bundle_policy = gstPolicy; } catch { /* ignore */ }
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ---- Properties --------------------------------------------------------
|
|
270
|
-
|
|
271
|
-
get signalingState(): RTCSignalingState {
|
|
272
|
-
if (this._closed) return 'closed';
|
|
273
|
-
try { return gstToSignalingState((this._webrtcbin as any).signaling_state); }
|
|
274
|
-
catch { return 'stable'; }
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
get connectionState(): RTCPeerConnectionState {
|
|
278
|
-
if (this._closed) return 'closed';
|
|
279
|
-
try { return gstToConnectionState((this._webrtcbin as any).connection_state); }
|
|
280
|
-
catch { return 'new'; }
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
get iceConnectionState(): RTCIceConnectionState {
|
|
284
|
-
if (this._closed) return 'closed';
|
|
285
|
-
try { return gstToIceConnectionState((this._webrtcbin as any).ice_connection_state); }
|
|
286
|
-
catch { return 'new'; }
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
get iceGatheringState(): RTCIceGatheringState {
|
|
290
|
-
try { return gstToIceGatheringState((this._webrtcbin as any).ice_gathering_state); }
|
|
291
|
-
catch { return 'new'; }
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
private _descProp(prop: string): RTCSessionDescription | null {
|
|
295
|
-
try {
|
|
296
|
-
const desc = (this._webrtcbin as any)[prop] as GstWebRTC.WebRTCSessionDescription | null;
|
|
297
|
-
if (!desc) return null;
|
|
298
|
-
return RTCSessionDescription.fromGstDesc(desc);
|
|
299
|
-
} catch { return null; }
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
get localDescription(): RTCSessionDescription | null { return this._descProp('local_description'); }
|
|
303
|
-
get remoteDescription(): RTCSessionDescription | null { return this._descProp('remote_description'); }
|
|
304
|
-
get currentLocalDescription(): RTCSessionDescription | null { return this._descProp('current_local_description'); }
|
|
305
|
-
get currentRemoteDescription(): RTCSessionDescription | null { return this._descProp('current_remote_description'); }
|
|
306
|
-
get pendingLocalDescription(): RTCSessionDescription | null { return this._descProp('pending_local_description'); }
|
|
307
|
-
get pendingRemoteDescription(): RTCSessionDescription | null { return this._descProp('pending_remote_description'); }
|
|
308
|
-
|
|
309
|
-
get sctp(): RTCSctpTransport | null { return this._sctpTransport; }
|
|
310
|
-
get peerIdentity(): Promise<never> {
|
|
311
|
-
return Promise.reject(new TypeError('peerIdentity assertions are not implemented'));
|
|
312
|
-
}
|
|
313
|
-
get idpErrorInfo(): null { return null; }
|
|
314
|
-
get idpLoginUrl(): null { return null; }
|
|
315
|
-
|
|
316
|
-
// ---- Core methods ------------------------------------------------------
|
|
317
|
-
|
|
318
|
-
private _rejectIfClosed(method: string): void {
|
|
319
|
-
if (!this._closed) return;
|
|
320
|
-
throw new DOMException(
|
|
321
|
-
`RTCPeerConnection.${method}: connection is closed`,
|
|
322
|
-
'InvalidStateError',
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async createOffer(_options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
|
327
|
-
this._rejectIfClosed('createOffer');
|
|
328
|
-
const opts = Gst.Structure.new_empty('offer-options');
|
|
329
|
-
// If restartIce() was called, request fresh ICE credentials
|
|
330
|
-
if (this._iceRestartNeeded) {
|
|
331
|
-
this._setStructureField(opts, 'ice-restart', 'boolean', true);
|
|
332
|
-
this._iceRestartNeeded = false;
|
|
333
|
-
}
|
|
334
|
-
const reply = await withGstPromise((p) => {
|
|
335
|
-
this._webrtcbin.emit('create-offer', opts, p);
|
|
336
|
-
});
|
|
337
|
-
// GJS unboxes `get_value` for boxed types directly to the underlying
|
|
338
|
-
// struct; no GObject.Value wrapper involvement.
|
|
339
|
-
const desc = reply!.get_value('offer') as unknown as GstWebRTC.WebRTCSessionDescription;
|
|
340
|
-
return RTCSessionDescription.fromGstDesc(desc).toJSON();
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async createAnswer(_options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit> {
|
|
344
|
-
this._rejectIfClosed('createAnswer');
|
|
345
|
-
const opts = Gst.Structure.new_empty('answer-options');
|
|
346
|
-
const reply = await withGstPromise((p) => {
|
|
347
|
-
this._webrtcbin.emit('create-answer', opts, p);
|
|
348
|
-
});
|
|
349
|
-
const desc = reply!.get_value('answer') as unknown as GstWebRTC.WebRTCSessionDescription;
|
|
350
|
-
return RTCSessionDescription.fromGstDesc(desc).toJSON();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async setLocalDescription(description?: RTCSessionDescriptionInit): Promise<void> {
|
|
354
|
-
this._rejectIfClosed('setLocalDescription');
|
|
355
|
-
|
|
356
|
-
// W3C § 4.4.1.6 — implicit setLocalDescription (perfect negotiation):
|
|
357
|
-
// When called without arguments (or with empty type/sdp), auto-create
|
|
358
|
-
// the appropriate SDP based on the current signaling state.
|
|
359
|
-
if (!description || !description.type || !description.sdp) {
|
|
360
|
-
const state = this.signalingState;
|
|
361
|
-
if (state === 'stable' || state === 'have-local-offer') {
|
|
362
|
-
// Stable → create offer; have-local-offer → rollback + re-offer
|
|
363
|
-
description = await this.createOffer();
|
|
364
|
-
} else if (state === 'have-remote-offer' || state === 'have-remote-pranswer') {
|
|
365
|
-
description = await this.createAnswer();
|
|
366
|
-
} else {
|
|
367
|
-
throw new DOMException(
|
|
368
|
-
`setLocalDescription: cannot auto-create SDP in signalingState '${state}'`,
|
|
369
|
-
'InvalidStateError',
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// On first-time setLocalDescription, the pipeline needs to start running.
|
|
375
|
-
this._pipeline.set_state(Gst.State.PLAYING);
|
|
376
|
-
const gstDesc = new RTCSessionDescription(description).toGstDesc();
|
|
377
|
-
await withGstPromise((p) => {
|
|
378
|
-
this._webrtcbin.emit('set-local-description', gstDesc, p);
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
|
383
|
-
this._rejectIfClosed('setRemoteDescription');
|
|
384
|
-
if (!description || !description.sdp || !description.type) {
|
|
385
|
-
throw new TypeError('setRemoteDescription requires an RTCSessionDescriptionInit with sdp and type');
|
|
386
|
-
}
|
|
387
|
-
this._pipeline.set_state(Gst.State.PLAYING);
|
|
388
|
-
const gstDesc = new RTCSessionDescription(description).toGstDesc();
|
|
389
|
-
await withGstPromise((p) => {
|
|
390
|
-
this._webrtcbin.emit('set-remote-description', gstDesc, p);
|
|
391
|
-
});
|
|
392
|
-
// Track that at least one negotiation has completed (for restartIce)
|
|
393
|
-
if (this.signalingState === 'stable') {
|
|
394
|
-
this._hasNegotiated = true;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async addIceCandidate(candidate: RTCIceCandidateInit | RTCIceCandidate | null): Promise<void> {
|
|
399
|
-
this._rejectIfClosed('addIceCandidate');
|
|
400
|
-
if (!candidate) return; // end-of-candidates marker — webrtcbin handles implicitly
|
|
401
|
-
const { candidate: cand, sdpMLineIndex } = candidate;
|
|
402
|
-
if (typeof cand !== 'string' || typeof sdpMLineIndex !== 'number') return;
|
|
403
|
-
this._webrtcbin.emit('add-ice-candidate', sdpMLineIndex, cand);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
createDataChannel(label: string, options: RTCDataChannelInit = {}): RTCDataChannel {
|
|
407
|
-
if (this._closed) {
|
|
408
|
-
throw new DOMException(
|
|
409
|
-
'Cannot create a data channel on a closed RTCPeerConnection',
|
|
410
|
-
'InvalidStateError',
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
if (typeof label !== 'string') {
|
|
414
|
-
throw new TypeError('createDataChannel: label must be a string');
|
|
415
|
-
}
|
|
416
|
-
if (new TextEncoder().encode(label).byteLength > 65535) {
|
|
417
|
-
throw new TypeError('createDataChannel: label too long (> 65535 bytes)');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Web-IDL `[EnforceRange] unsigned short` coercion for the three
|
|
421
|
-
// numeric options. Input is coerced via ToNumber (so "100" → 100)
|
|
422
|
-
// then range-checked against [0, 65535]; any value that can't be
|
|
423
|
-
// represented exactly as an unsigned short throws TypeError. Also
|
|
424
|
-
// handles WPT's `0` edge case (number) vs `undefined` (no value).
|
|
425
|
-
const maxPacketLifeTime = options.maxPacketLifeTime == null
|
|
426
|
-
? undefined
|
|
427
|
-
: coerceUnsignedShort('maxPacketLifeTime', options.maxPacketLifeTime);
|
|
428
|
-
const maxRetransmits = options.maxRetransmits == null
|
|
429
|
-
? undefined
|
|
430
|
-
: coerceUnsignedShort('maxRetransmits', options.maxRetransmits);
|
|
431
|
-
const id = options.id == null
|
|
432
|
-
? undefined
|
|
433
|
-
: coerceUnsignedShort('id', options.id);
|
|
434
|
-
|
|
435
|
-
if (maxPacketLifeTime !== undefined && maxRetransmits !== undefined) {
|
|
436
|
-
throw new TypeError('createDataChannel: maxPacketLifeTime and maxRetransmits are mutually exclusive');
|
|
437
|
-
}
|
|
438
|
-
if (options.negotiated === true && id === undefined) {
|
|
439
|
-
throw new TypeError('createDataChannel: negotiated=true requires an id');
|
|
440
|
-
}
|
|
441
|
-
if (id === 65535) {
|
|
442
|
-
// Per RFC 8832 §5.1, id must be < 65535 (65535 is reserved).
|
|
443
|
-
throw new TypeError('createDataChannel: id 65535 is reserved');
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const gstOpts = Gst.Structure.new_empty('data-channel-opts');
|
|
447
|
-
this._setStructureField(gstOpts, 'ordered', 'boolean', options.ordered);
|
|
448
|
-
this._setStructureField(gstOpts, 'max-packet-lifetime', 'int', maxPacketLifeTime);
|
|
449
|
-
this._setStructureField(gstOpts, 'max-retransmits', 'int', maxRetransmits);
|
|
450
|
-
this._setStructureField(gstOpts, 'protocol', 'string', options.protocol);
|
|
451
|
-
this._setStructureField(gstOpts, 'negotiated', 'boolean', options.negotiated);
|
|
452
|
-
this._setStructureField(gstOpts, 'id', 'int', id);
|
|
453
|
-
|
|
454
|
-
let native: GstWebRTC.WebRTCDataChannel | null = null;
|
|
455
|
-
try {
|
|
456
|
-
native = this._webrtcbin.emit('create-data-channel', label, gstOpts) as any;
|
|
457
|
-
} catch (err: any) {
|
|
458
|
-
throw new Error(`create-data-channel failed: ${err?.message ?? err}`);
|
|
459
|
-
}
|
|
460
|
-
if (!native) {
|
|
461
|
-
throw new Error('webrtcbin returned null data channel (check id/label/options)');
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Data channel created → ensure SCTP transport exists
|
|
465
|
-
this._ensureSctpTransport();
|
|
466
|
-
|
|
467
|
-
const js = new RTCDataChannel(native);
|
|
468
|
-
this._dataChannels.set(native, js);
|
|
469
|
-
js.addEventListener('close', () => {
|
|
470
|
-
this._dataChannels.delete(native);
|
|
471
|
-
});
|
|
472
|
-
return js;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
private _setStructureField(
|
|
476
|
-
structure: Gst.Structure,
|
|
477
|
-
name: string,
|
|
478
|
-
type: 'boolean' | 'int' | 'string',
|
|
479
|
-
value: unknown,
|
|
480
|
-
): void {
|
|
481
|
-
if (value == null) return;
|
|
482
|
-
const gvalue = new GObject.Value();
|
|
483
|
-
if (type === 'boolean') {
|
|
484
|
-
gvalue.init(GObject.TYPE_BOOLEAN);
|
|
485
|
-
gvalue.set_boolean(Boolean(value));
|
|
486
|
-
} else if (type === 'int') {
|
|
487
|
-
gvalue.init(GObject.TYPE_INT);
|
|
488
|
-
gvalue.set_int(Number(value));
|
|
489
|
-
} else if (type === 'string') {
|
|
490
|
-
gvalue.init(GObject.TYPE_STRING);
|
|
491
|
-
gvalue.set_string(String(value));
|
|
492
|
-
}
|
|
493
|
-
structure.set_value(name, gvalue);
|
|
494
|
-
gvalue.unset();
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
getConfiguration(): RTCConfiguration { return { ...this._conf }; }
|
|
498
|
-
|
|
499
|
-
close(): void {
|
|
500
|
-
if (this._closed) return;
|
|
501
|
-
this._closed = true;
|
|
502
|
-
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
|
|
503
|
-
try { this._pipeline.set_state(Gst.State.NULL); } catch { /* ignore */ }
|
|
504
|
-
for (const ch of this._dataChannels.values()) {
|
|
505
|
-
try { ch._disconnectSignals(); } catch { /* ignore */ }
|
|
506
|
-
}
|
|
507
|
-
this._dataChannels.clear();
|
|
508
|
-
for (const s of this._senders) {
|
|
509
|
-
try { s._teardownPipeline(); } catch { /* ignore */ }
|
|
510
|
-
}
|
|
511
|
-
for (const r of this._receivers) {
|
|
512
|
-
try { r._dispose(); } catch { /* ignore */ }
|
|
513
|
-
}
|
|
514
|
-
this._transceivers.clear();
|
|
515
|
-
this._senders.length = 0;
|
|
516
|
-
this._receivers.length = 0;
|
|
517
|
-
// Close transport objects
|
|
518
|
-
if (this._dtlsTransport) this._dtlsTransport._setState('closed');
|
|
519
|
-
if (this._iceTransport) this._iceTransport._setState('closed');
|
|
520
|
-
if (this._sctpTransport) this._sctpTransport._setState('closed');
|
|
521
|
-
try { this._bridge.dispose_bridge(); } catch { /* ignore */ }
|
|
522
|
-
return GLib.SOURCE_REMOVE;
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ---- Media / Transceiver API (Phase 2) ----------------------------------
|
|
527
|
-
|
|
528
|
-
addTransceiver(
|
|
529
|
-
trackOrKind: MediaStreamTrack | string,
|
|
530
|
-
init?: RTCRtpTransceiverInit,
|
|
531
|
-
): RTCRtpTransceiver {
|
|
532
|
-
this._rejectIfClosed('addTransceiver');
|
|
533
|
-
|
|
534
|
-
let kind: 'audio' | 'video';
|
|
535
|
-
if (typeof trackOrKind === 'string') {
|
|
536
|
-
if (trackOrKind !== 'audio' && trackOrKind !== 'video') {
|
|
537
|
-
throw new TypeError(
|
|
538
|
-
`Failed to execute 'addTransceiver' on 'RTCPeerConnection': The provided value '${trackOrKind}' is not a valid enum value of type MediaStreamTrackKind.`,
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
kind = trackOrKind;
|
|
542
|
-
} else if (trackOrKind instanceof MediaStreamTrack) {
|
|
543
|
-
kind = trackOrKind.kind;
|
|
544
|
-
} else {
|
|
545
|
-
throw new TypeError(
|
|
546
|
-
"Failed to execute 'addTransceiver' on 'RTCPeerConnection': parameter 1 is not of type 'MediaStreamTrack' or a valid MediaStreamTrackKind.",
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (init?.sendEncodings) {
|
|
551
|
-
const rids = new Set<string>();
|
|
552
|
-
for (const enc of init.sendEncodings) {
|
|
553
|
-
if (enc.rid !== undefined) {
|
|
554
|
-
if (typeof enc.rid !== 'string' || enc.rid.length === 0 || enc.rid.length > 16 || !/^[a-zA-Z0-9]+$/.test(enc.rid)) {
|
|
555
|
-
throw new TypeError(`Invalid RID value: ${enc.rid}`);
|
|
556
|
-
}
|
|
557
|
-
if (rids.has(enc.rid)) {
|
|
558
|
-
throw new TypeError(`Duplicate RID: ${enc.rid}`);
|
|
559
|
-
}
|
|
560
|
-
rids.add(enc.rid);
|
|
561
|
-
}
|
|
562
|
-
if (enc.scaleResolutionDownBy !== undefined && enc.scaleResolutionDownBy < 1.0) {
|
|
563
|
-
throw new RangeError('scaleResolutionDownBy must be >= 1.0');
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const direction = init?.direction ?? 'sendrecv';
|
|
569
|
-
const validDirections = ['sendrecv', 'sendonly', 'recvonly', 'inactive'];
|
|
570
|
-
if (!validDirections.includes(direction)) {
|
|
571
|
-
throw new TypeError(
|
|
572
|
-
`Failed to execute 'addTransceiver' on 'RTCPeerConnection': The provided value '${direction}' is not a valid enum value of type RTCRtpTransceiverDirection.`,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
const hasGstSource = trackOrKind instanceof MediaStreamTrack && (trackOrKind as any)._gstSource;
|
|
576
|
-
const wantsSend = direction === 'sendrecv' || direction === 'sendonly';
|
|
577
|
-
|
|
578
|
-
let gstTrans: any;
|
|
579
|
-
let jsTrans: RTCRtpTransceiver;
|
|
580
|
-
|
|
581
|
-
if (hasGstSource && wantsSend) {
|
|
582
|
-
// Path A: Track has a GStreamer source and needs to send.
|
|
583
|
-
// Requesting a sink pad from webrtcbin implicitly creates both
|
|
584
|
-
// the pad AND the transceiver. Using emit('add-transceiver')
|
|
585
|
-
// would create a duplicate with mline=-1.
|
|
586
|
-
const track = trackOrKind as MediaStreamTrack;
|
|
587
|
-
|
|
588
|
-
// Build encoder chain, link to webrtcbin via request_pad_simple
|
|
589
|
-
const sender = new RTCRtpSender(null, this._pipeline, this._webrtcbin);
|
|
590
|
-
sender._kind = kind;
|
|
591
|
-
// Allow sender to update our pipeline if it migrates to a VideoBridge pipeline
|
|
592
|
-
sender._onPipelineChanged = (newPipeline) => { this._pipeline = newPipeline; };
|
|
593
|
-
sender._setTrack(track);
|
|
594
|
-
sender._wirePipeline(track);
|
|
595
|
-
|
|
596
|
-
// Find the GstTransceiver that request_pad_simple created
|
|
597
|
-
gstTrans = this._findNewGstTransceiver();
|
|
598
|
-
if (!gstTrans) {
|
|
599
|
-
throw new Error('webrtcbin did not create a transceiver for the send pad');
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// Create wrapper with the pre-wired sender
|
|
603
|
-
const gstReceiver = gstTrans.receiver ?? null;
|
|
604
|
-
const receiver = new RTCRtpReceiver(kind, gstReceiver, this._pipeline);
|
|
605
|
-
|
|
606
|
-
// Wire stats delegation + transport
|
|
607
|
-
const statsDelegate = (t: MediaStreamTrack) => this.getStats(t);
|
|
608
|
-
sender._getStatsForTrack = statsDelegate;
|
|
609
|
-
receiver._getStatsForTrack = statsDelegate;
|
|
610
|
-
const dtls = this._ensureTransports();
|
|
611
|
-
sender._transport = dtls;
|
|
612
|
-
receiver._transport = dtls;
|
|
613
|
-
|
|
614
|
-
jsTrans = new RTCRtpTransceiver(gstTrans, sender, receiver);
|
|
615
|
-
sender._transceiver = jsTrans;
|
|
616
|
-
this._transceivers.set(gstTrans, jsTrans);
|
|
617
|
-
this._senders.push(sender);
|
|
618
|
-
this._receivers.push(receiver);
|
|
619
|
-
|
|
620
|
-
// Apply direction
|
|
621
|
-
(gstTrans as any).direction = w3cDirectionToGst(direction);
|
|
622
|
-
} else {
|
|
623
|
-
// Path B: No GStreamer source, or receive-only/inactive.
|
|
624
|
-
// Use emit('add-transceiver') which creates a transceiver without pads.
|
|
625
|
-
const caps = Gst.Caps.from_string(`application/x-rtp,media=${kind}`);
|
|
626
|
-
// webrtcbin doesn't accept NONE for add-transceiver; use SENDRECV
|
|
627
|
-
// and override to inactive after creation.
|
|
628
|
-
const createDirection = direction === 'inactive'
|
|
629
|
-
? w3cDirectionToGst('sendrecv')
|
|
630
|
-
: w3cDirectionToGst(direction);
|
|
631
|
-
|
|
632
|
-
gstTrans = this._webrtcbin.emit('add-transceiver', createDirection, caps) as any;
|
|
633
|
-
if (!gstTrans) {
|
|
634
|
-
throw new Error('webrtcbin did not create a transceiver');
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
jsTrans = this._transceivers.get(gstTrans)!;
|
|
638
|
-
if (!jsTrans) {
|
|
639
|
-
jsTrans = this._createTransceiverWrapper(gstTrans);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
(gstTrans as any).direction = w3cDirectionToGst(direction);
|
|
643
|
-
|
|
644
|
-
if (trackOrKind instanceof MediaStreamTrack) {
|
|
645
|
-
jsTrans.sender._setTrack(trackOrKind);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
return jsTrans;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
addTrack(track: MediaStreamTrack, ..._streams: MediaStream[]): RTCRtpSender {
|
|
653
|
-
this._rejectIfClosed('addTrack');
|
|
654
|
-
|
|
655
|
-
if (!(track instanceof MediaStreamTrack)) {
|
|
656
|
-
throw new TypeError(
|
|
657
|
-
"Failed to execute 'addTrack' on 'RTCPeerConnection': parameter 1 is not a MediaStreamTrack",
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Check if this track is already assigned to a sender
|
|
662
|
-
const existing = this._senders.find(s => s.track === track);
|
|
663
|
-
if (existing) {
|
|
664
|
-
throw new DOMException(
|
|
665
|
-
'Track already exists in a sender of this connection',
|
|
666
|
-
'InvalidAccessError',
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Look for a reusable transceiver (matching kind, no track, recvonly/inactive)
|
|
671
|
-
let reusable: RTCRtpTransceiver | undefined;
|
|
672
|
-
for (const t of this._transceivers.values()) {
|
|
673
|
-
if (
|
|
674
|
-
t.sender.track === null &&
|
|
675
|
-
!t.stopped &&
|
|
676
|
-
t.direction !== 'stopped' &&
|
|
677
|
-
t.receiver.track.kind === track.kind
|
|
678
|
-
) {
|
|
679
|
-
const dir = t.direction;
|
|
680
|
-
if (dir === 'recvonly' || dir === 'inactive') {
|
|
681
|
-
reusable = t;
|
|
682
|
-
break;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
if (reusable) {
|
|
688
|
-
// Expand direction to include send
|
|
689
|
-
const dir = reusable.direction;
|
|
690
|
-
reusable.direction = dir === 'recvonly' ? 'sendrecv' : 'sendonly';
|
|
691
|
-
reusable.sender._setTrack(track);
|
|
692
|
-
// Note: _wirePipeline is NOT called here for reusable transceivers.
|
|
693
|
-
// Tracks with GStreamer sources will be handled by addTransceiver Path A
|
|
694
|
-
// if no reusable transceiver exists, or the pipeline will be wired
|
|
695
|
-
// when webrtcbin creates the sink pad during SDP negotiation.
|
|
696
|
-
return reusable.sender;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Create a new transceiver — addTransceiver handles both _setTrack
|
|
700
|
-
// and _wirePipeline for tracks with GStreamer sources (Path A).
|
|
701
|
-
const transceiver = this.addTransceiver(track, { direction: 'sendrecv' });
|
|
702
|
-
return transceiver.sender;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
removeTrack(sender: RTCRtpSender): void {
|
|
706
|
-
this._rejectIfClosed('removeTrack');
|
|
707
|
-
if (!this._senders.includes(sender)) {
|
|
708
|
-
throw new DOMException(
|
|
709
|
-
'sender was not created by this connection',
|
|
710
|
-
'InvalidAccessError',
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
sender._setTrack(null);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
getSenders(): RTCRtpSender[] { return [...this._senders]; }
|
|
717
|
-
getReceivers(): RTCRtpReceiver[] { return [...this._receivers]; }
|
|
718
|
-
getTransceivers(): RTCRtpTransceiver[] { return [...this._transceivers.values()]; }
|
|
719
|
-
|
|
720
|
-
async getStats(selector?: MediaStreamTrack | null): Promise<RTCStatsReport> {
|
|
721
|
-
this._rejectIfClosed('getStats');
|
|
722
|
-
|
|
723
|
-
// Validate selector — if a track is given, it must belong to a sender or receiver
|
|
724
|
-
if (selector != null && selector instanceof MediaStreamTrack) {
|
|
725
|
-
const hasSender = this._senders.some(s => s.track === selector);
|
|
726
|
-
const hasReceiver = this._receivers.some(r => r.track === selector);
|
|
727
|
-
if (!hasSender && !hasReceiver) {
|
|
728
|
-
throw new DOMException(
|
|
729
|
-
'The selector track is not associated with a sender or receiver of this connection',
|
|
730
|
-
'InvalidAccessError',
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const reply = await withGstPromise((p) => {
|
|
736
|
-
this._webrtcbin.emit('get-stats', null, p);
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
const report = parseGstStats(reply);
|
|
740
|
-
|
|
741
|
-
// If a track selector was provided, filter to relevant stats
|
|
742
|
-
if (selector != null && selector instanceof MediaStreamTrack) {
|
|
743
|
-
return filterStatsByTrackId(report, selector.id);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
return report;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// ---- ICE restart / reconfiguration (Phase 4.4) ---------------------------
|
|
750
|
-
|
|
751
|
-
restartIce(): void {
|
|
752
|
-
if (this._closed) return; // no-op on closed connections per spec
|
|
753
|
-
this._iceRestartNeeded = true;
|
|
754
|
-
// Only fire negotiationneeded if we've completed at least one negotiation.
|
|
755
|
-
// Before initial negotiation, restartIce has no observable effect.
|
|
756
|
-
if (this._hasNegotiated) {
|
|
757
|
-
// Fire asynchronously per spec (queued as a microtask)
|
|
758
|
-
Promise.resolve().then(() => {
|
|
759
|
-
if (this._closed) return;
|
|
760
|
-
this._handleNegotiationNeeded();
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
setConfiguration(configuration: RTCConfiguration): void {
|
|
766
|
-
this._rejectIfClosed('setConfiguration');
|
|
767
|
-
|
|
768
|
-
// Per spec: bundlePolicy and rtcpMuxPolicy cannot change after construction
|
|
769
|
-
if (configuration.bundlePolicy && configuration.bundlePolicy !== (this._conf.bundlePolicy ?? 'balanced')) {
|
|
770
|
-
throw new DOMException(
|
|
771
|
-
'setConfiguration: bundlePolicy cannot be changed',
|
|
772
|
-
'InvalidModificationError',
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
if (configuration.rtcpMuxPolicy && configuration.rtcpMuxPolicy !== (this._conf.rtcpMuxPolicy ?? 'require')) {
|
|
776
|
-
throw new DOMException(
|
|
777
|
-
'setConfiguration: rtcpMuxPolicy cannot be changed',
|
|
778
|
-
'InvalidModificationError',
|
|
779
|
-
);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Apply new ICE servers
|
|
783
|
-
if (configuration.iceServers) {
|
|
784
|
-
this._applyIceServers(configuration.iceServers);
|
|
785
|
-
}
|
|
786
|
-
// Apply new ICE transport policy
|
|
787
|
-
if (configuration.iceTransportPolicy) {
|
|
788
|
-
this._applyIceTransportPolicy(configuration.iceTransportPolicy);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
this._conf = { ...this._conf, ...configuration };
|
|
792
|
-
}
|
|
793
|
-
getIdentityAssertion(): Promise<never> {
|
|
794
|
-
return Promise.reject(new Error('getIdentityAssertion is not implemented'));
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// ---- Transceiver helper -------------------------------------------------
|
|
798
|
-
|
|
799
|
-
/** Find a GstWebRTCRTPTransceiver not yet in our map (created by request_pad_simple). */
|
|
800
|
-
private _findNewGstTransceiver(): any {
|
|
801
|
-
for (let i = 0; ; i++) {
|
|
802
|
-
const gt = this._webrtcbin.emit('get-transceiver', i) as any;
|
|
803
|
-
if (!gt) return null;
|
|
804
|
-
if (!this._transceivers.has(gt)) return gt;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/** Lazily create the shared DTLS and ICE transport instances (max-bundle → one pair). */
|
|
809
|
-
private _ensureTransports(): RTCDtlsTransport {
|
|
810
|
-
if (!this._dtlsTransport) {
|
|
811
|
-
this._iceTransport = new RTCIceTransport();
|
|
812
|
-
this._dtlsTransport = new RTCDtlsTransport(this._iceTransport);
|
|
813
|
-
}
|
|
814
|
-
return this._dtlsTransport;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/** Create the SCTP transport when a data channel is first negotiated. */
|
|
818
|
-
private _ensureSctpTransport(): void {
|
|
819
|
-
if (this._sctpTransport) return;
|
|
820
|
-
const dtls = this._ensureTransports();
|
|
821
|
-
this._sctpTransport = new RTCSctpTransport(dtls);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
private _createTransceiverWrapper(gstTrans: any): RTCRtpTransceiver {
|
|
825
|
-
let kind: 'audio' | 'video' = 'audio';
|
|
826
|
-
try {
|
|
827
|
-
const gstKind = gstTrans.kind;
|
|
828
|
-
if (gstKind === GstWebRTC.WebRTCKind.VIDEO) kind = 'video';
|
|
829
|
-
} catch { /* default audio */ }
|
|
830
|
-
|
|
831
|
-
const gstReceiver = gstTrans.receiver ?? null;
|
|
832
|
-
const gstSender = gstTrans.sender ?? null;
|
|
833
|
-
|
|
834
|
-
const receiver = new RTCRtpReceiver(kind, gstReceiver, this._pipeline);
|
|
835
|
-
const sender = new RTCRtpSender(gstSender, this._pipeline, this._webrtcbin);
|
|
836
|
-
sender._kind = kind;
|
|
837
|
-
sender._onPipelineChanged = (newPipeline) => { this._pipeline = newPipeline; };
|
|
838
|
-
|
|
839
|
-
// Wire stats delegation so sender.getStats() / receiver.getStats() work
|
|
840
|
-
const statsDelegate = (track: MediaStreamTrack) => this.getStats(track);
|
|
841
|
-
sender._getStatsForTrack = statsDelegate;
|
|
842
|
-
receiver._getStatsForTrack = statsDelegate;
|
|
843
|
-
|
|
844
|
-
// Assign shared DTLS transport to sender/receiver
|
|
845
|
-
const dtls = this._ensureTransports();
|
|
846
|
-
sender._transport = dtls;
|
|
847
|
-
receiver._transport = dtls;
|
|
848
|
-
|
|
849
|
-
// Pass mline index to sender for sink pad naming
|
|
850
|
-
try {
|
|
851
|
-
const mline = (gstTrans as any).mlineindex;
|
|
852
|
-
if (typeof mline === 'number' && mline >= 0) {
|
|
853
|
-
sender._setMlineIndex(mline);
|
|
854
|
-
}
|
|
855
|
-
} catch { /* ignore */ }
|
|
856
|
-
|
|
857
|
-
const transceiver = new RTCRtpTransceiver(gstTrans, sender, receiver);
|
|
858
|
-
sender._transceiver = transceiver;
|
|
859
|
-
|
|
860
|
-
this._transceivers.set(gstTrans, transceiver);
|
|
861
|
-
this._senders.push(sender);
|
|
862
|
-
this._receivers.push(receiver);
|
|
863
|
-
return transceiver;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// ---- Signal handlers ---------------------------------------------------
|
|
867
|
-
// The WebrtcbinBridge (webrtc-native) has already marshalled these from
|
|
868
|
-
// the GStreamer streaming thread onto the GLib main context, so we can
|
|
869
|
-
// synchronously dispatch from here.
|
|
870
|
-
|
|
871
|
-
private _handleNegotiationNeeded(): void {
|
|
872
|
-
const ev = new Event('negotiationneeded');
|
|
873
|
-
this._onnegotiationneeded?.call(this, ev);
|
|
874
|
-
this.dispatchEvent(ev);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
private _handleIceCandidate(sdpMLineIndex: number, candidate: string): void {
|
|
878
|
-
const cand = new RTCIceCandidate({ candidate, sdpMLineIndex });
|
|
879
|
-
const ev = new RTCPeerConnectionIceEvent('icecandidate', { candidate: cand });
|
|
880
|
-
this._onicecandidate?.call(this, ev);
|
|
881
|
-
this.dispatchEvent(ev);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
private _handleNewTransceiver(gstTrans: GstWebRTC.WebRTCRTPTransceiver): void {
|
|
885
|
-
if (this._closed) return;
|
|
886
|
-
if (this._transceivers.has(gstTrans)) return;
|
|
887
|
-
this._createTransceiverWrapper(gstTrans);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
private _handlePadAdded(pad: Gst.Pad): void {
|
|
891
|
-
if (this._closed) return;
|
|
892
|
-
// Only process SRC pads (incoming media from remote peer)
|
|
893
|
-
if (pad.direction !== Gst.PadDirection.SRC) return;
|
|
894
|
-
|
|
895
|
-
const gstTrans = (pad as any).transceiver;
|
|
896
|
-
if (!gstTrans) return;
|
|
897
|
-
|
|
898
|
-
let jsTrans = this._transceivers.get(gstTrans);
|
|
899
|
-
if (!jsTrans) {
|
|
900
|
-
jsTrans = this._createTransceiverWrapper(gstTrans);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Phase 2.5: wire incoming media through ReceiverBridge (decodebin → tee)
|
|
904
|
-
jsTrans.receiver._connectToPad(pad);
|
|
905
|
-
|
|
906
|
-
const stream = new MediaStream([jsTrans.receiver.track]);
|
|
907
|
-
const ev = new RTCTrackEvent('track', {
|
|
908
|
-
receiver: jsTrans.receiver,
|
|
909
|
-
track: jsTrans.receiver.track,
|
|
910
|
-
streams: [stream],
|
|
911
|
-
transceiver: jsTrans,
|
|
912
|
-
});
|
|
913
|
-
this._ontrack?.call(this, ev);
|
|
914
|
-
this.dispatchEvent(ev);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
private _handleDataChannel(channelBridge: DataChannelBridgeType): void {
|
|
918
|
-
this._ensureSctpTransport();
|
|
919
|
-
const native = channelBridge.channel as unknown as GstWebRTC.WebRTCDataChannel;
|
|
920
|
-
let js = this._dataChannels.get(native);
|
|
921
|
-
if (!js) {
|
|
922
|
-
js = new RTCDataChannel(channelBridge);
|
|
923
|
-
this._dataChannels.set(native, js);
|
|
924
|
-
js.addEventListener('close', () => {
|
|
925
|
-
this._dataChannels.delete(native);
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
const ev = new RTCDataChannelEvent('datachannel', { channel: js });
|
|
929
|
-
this._ondatachannel?.call(this, ev);
|
|
930
|
-
this.dispatchEvent(ev);
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
private _dispatchStateChange(type: string): void {
|
|
934
|
-
// Sync transport object states from webrtcbin before dispatching
|
|
935
|
-
if (type === 'connectionstatechange') {
|
|
936
|
-
this._syncDtlsState();
|
|
937
|
-
} else if (type === 'iceconnectionstatechange') {
|
|
938
|
-
this._syncIceState();
|
|
939
|
-
} else if (type === 'icegatheringstatechange') {
|
|
940
|
-
this._syncIceGatheringState();
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
const ev = new Event(type);
|
|
944
|
-
switch (type) {
|
|
945
|
-
case 'connectionstatechange': this._onconnectionstatechange?.call(this, ev); break;
|
|
946
|
-
case 'iceconnectionstatechange': this._oniceconnectionstatechange?.call(this, ev); break;
|
|
947
|
-
case 'icegatheringstatechange': this._onicegatheringstatechange?.call(this, ev); break;
|
|
948
|
-
case 'signalingstatechange': this._onsignalingstatechange?.call(this, ev); break;
|
|
949
|
-
}
|
|
950
|
-
this.dispatchEvent(ev);
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/** Map PC connection state → DTLS transport state. */
|
|
954
|
-
private _syncDtlsState(): void {
|
|
955
|
-
if (!this._dtlsTransport) return;
|
|
956
|
-
const pcState = this.connectionState;
|
|
957
|
-
const dtlsMap: Record<string, 'new' | 'connecting' | 'connected' | 'closed' | 'failed'> = {
|
|
958
|
-
'new': 'new',
|
|
959
|
-
'connecting': 'connecting',
|
|
960
|
-
'connected': 'connected',
|
|
961
|
-
'disconnected': 'connected', // DTLS stays connected even if ICE disconnects
|
|
962
|
-
'failed': 'failed',
|
|
963
|
-
'closed': 'closed',
|
|
964
|
-
};
|
|
965
|
-
this._dtlsTransport._setState(dtlsMap[pcState] ?? 'new');
|
|
966
|
-
|
|
967
|
-
// Connected DTLS → SCTP connected
|
|
968
|
-
if (pcState === 'connected' && this._sctpTransport) {
|
|
969
|
-
this._sctpTransport._setState('connected');
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/** Map PC ICE connection state → ICE transport state. */
|
|
974
|
-
private _syncIceState(): void {
|
|
975
|
-
if (!this._iceTransport) return;
|
|
976
|
-
const iceState = this.iceConnectionState;
|
|
977
|
-
this._iceTransport._setState(iceState as any);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/** Map PC ICE gathering state → ICE transport gathering state. */
|
|
981
|
-
private _syncIceGatheringState(): void {
|
|
982
|
-
if (!this._iceTransport) return;
|
|
983
|
-
const gatheringState = this.iceGatheringState;
|
|
984
|
-
this._iceTransport._setGatheringState(gatheringState as any);
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// ---- on<event> attribute handlers --------------------------------------
|
|
988
|
-
|
|
989
|
-
private _onconnectionstatechange: EventHandler = null;
|
|
990
|
-
private _ondatachannel: EventHandler<RTCDataChannelEvent> = null;
|
|
991
|
-
private _onicecandidate: EventHandler<RTCPeerConnectionIceEvent> = null;
|
|
992
|
-
private _oniceconnectionstatechange: EventHandler = null;
|
|
993
|
-
private _onicegatheringstatechange: EventHandler = null;
|
|
994
|
-
private _onnegotiationneeded: EventHandler = null;
|
|
995
|
-
private _onsignalingstatechange: EventHandler = null;
|
|
996
|
-
|
|
997
|
-
get onconnectionstatechange() { return this._onconnectionstatechange; }
|
|
998
|
-
set onconnectionstatechange(v: EventHandler) { this._onconnectionstatechange = v; }
|
|
999
|
-
get ondatachannel() { return this._ondatachannel; }
|
|
1000
|
-
set ondatachannel(v: EventHandler<RTCDataChannelEvent>) { this._ondatachannel = v; }
|
|
1001
|
-
get onicecandidate() { return this._onicecandidate; }
|
|
1002
|
-
set onicecandidate(v: EventHandler<RTCPeerConnectionIceEvent>) { this._onicecandidate = v; }
|
|
1003
|
-
get oniceconnectionstatechange() { return this._oniceconnectionstatechange; }
|
|
1004
|
-
set oniceconnectionstatechange(v: EventHandler) { this._oniceconnectionstatechange = v; }
|
|
1005
|
-
get onicegatheringstatechange() { return this._onicegatheringstatechange; }
|
|
1006
|
-
set onicegatheringstatechange(v: EventHandler) { this._onicegatheringstatechange = v; }
|
|
1007
|
-
get onnegotiationneeded() { return this._onnegotiationneeded; }
|
|
1008
|
-
set onnegotiationneeded(v: EventHandler) { this._onnegotiationneeded = v; }
|
|
1009
|
-
get onsignalingstatechange() { return this._onsignalingstatechange; }
|
|
1010
|
-
set onsignalingstatechange(v: EventHandler) { this._onsignalingstatechange = v; }
|
|
1011
|
-
|
|
1012
|
-
private _ontrack: EventHandler<RTCTrackEvent> = null;
|
|
1013
|
-
get ontrack() { return this._ontrack; }
|
|
1014
|
-
set ontrack(v: EventHandler<RTCTrackEvent>) { this._ontrack = v; }
|
|
1015
|
-
get onicecandidateerror(): EventHandler { return null; }
|
|
1016
|
-
set onicecandidateerror(_v: EventHandler) { /* no-op */ }
|
|
1017
|
-
|
|
1018
|
-
// ---- Certificate management (Phase 4.7) --------------------------------
|
|
1019
|
-
|
|
1020
|
-
static generateCertificate(keygenAlgorithm: AlgorithmIdentifier): Promise<RTCCertificate> {
|
|
1021
|
-
return generateCertificate(keygenAlgorithm);
|
|
1022
|
-
}
|
|
1023
|
-
}
|