lecodes-sdk 2.2.7 → 2.2.9

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.
@@ -0,0 +1,765 @@
1
+ // GENERATED by scripts/vendor-plugins.ts from lecodes-plugins/plugins/webrtc/sdk/webrtc.ts — do not edit here.
2
+ // Change it in the plugin repo and run `bun run vendor:plugins`; `bun run check:plugins` fails when this copy is stale.
3
+ // The WebRTC API of the browser, over the plugin's wire: RTCPeerConnection, MediaStream,
4
+ // MediaStreamTrack, RTCDataChannel, RTCRtpSender / Receiver / Transceiver, mediaDevices, plus
5
+ // RTCVideoView — the tree node that draws a track (the browser's <video> has no twin in a tree
6
+ // of nodes). Code written against the browser reads the same here; a client of an SFU
7
+ // (mediasoup-client, LiveKit's) runs over these objects.
8
+ //
9
+ // What differs from the browser, because the objects live in the host and a call crosses to it:
10
+ // - what the browser answers at once from its own memory is a promise here when the host must
11
+ // be asked: `track.getSettings()`, `sender.getParameters()`, `channel.getBufferedAmount()`
12
+ // (there is no `bufferedAmount` property);
13
+ // - `pc.getTransceivers()` / `getSenders()` / `getReceivers()` answer from the SDK's copy, which
14
+ // is refreshed after every description is set and on every `track` event — the moments the
15
+ // mids and the directions change;
16
+ // - `createDataChannel` returns at once (the channel is "connecting"; what is sent before the
17
+ // host made it is queued).
18
+ //
19
+ // Hand-written over the generated wire because the wire names ids and the app names objects: the
20
+ // tracks, the senders, the transceivers and the channels are kept here, by the id the host gave
21
+ // them, and every event is turned into the browser's event on the object it concerns.
22
+
23
+ import type { Listener, NativeView } from "../../../../plugin"
24
+ import { RtcMediaBase, NAME as MEDIA_NAME } from "./webrtc.RtcMedia.gen"
25
+ import { RtcPeerConnectionBase, NAME as PC_NAME } from "./webrtc.RtcPeerConnection.gen"
26
+ import { RtcVideoBase, NAME as VIDEO_NAME } from "./webrtc.RtcVideo.gen"
27
+ import type {
28
+ AudioConstraints, AudioOutput, DataChannelInfo, DataChannelInit, DataChannelState, DeviceInfo, Encoding, FacingMode,
29
+ IceCandidate, IceConnectionState, IceGatheringState, IceServer, MediaKind, ObjectFit, OfferOptions,
30
+ PeerConnectionState, RtcConfiguration, RtcVideoEvents, SdpType, SenderInfo, SenderParameters, SessionDescription,
31
+ SignalingState, TrackInfo, TrackSettings, TransceiverDirection, TransceiverInfo, TransceiverInit, VideoConstraints,
32
+ } from "../contract"
33
+
34
+ export type {
35
+ AudioConstraints, AudioOutput, DataChannelInit, DataChannelState, DeviceInfo, Encoding, FacingMode, IceCandidate,
36
+ IceConnectionState, IceGatheringState, IceServer, MediaKind, ObjectFit, OfferOptions, PeerConnectionState,
37
+ RtcConfiguration, RtcVideoEvents, SdpType, SenderParameters, SessionDescription, SignalingState, TrackSettings,
38
+ TransceiverDirection, TransceiverInit, VideoConstraints,
39
+ }
40
+
41
+ const unsupported = (what: string, channel: string, kind: "service" | "view"): Error =>
42
+ new Error(`${what} isn't available on this host — it needs the "webrtc" plugin (app.json "plugins"), a registered "${channel}" ${kind}.`)
43
+
44
+ // ---- events: the browser's shape, on the objects of this file -------------------------------------
45
+
46
+ /** A listener of one event type; it gets the event, which carries `type` and `target`. */
47
+ export type RTCEventListener<E> = (event: E) => void
48
+
49
+ /** What every object of the API emits from: `addEventListener` / `removeEventListener`, and the
50
+ * `on<type>` properties the subclasses declare. */
51
+ export class RTCEventTarget {
52
+ private readonly _listeners = new Map<string, RTCEventListener<any>[]>()
53
+
54
+ addEventListener<E = any>(type: string, listener: RTCEventListener<E>): void {
55
+ let list = this._listeners.get(type)
56
+ if (!list) this._listeners.set(type, list = [])
57
+ if (!list.includes(listener)) list.push(listener)
58
+ }
59
+ removeEventListener<E = any>(type: string, listener: RTCEventListener<E>): void {
60
+ const list = this._listeners.get(type)
61
+ if (!list) return
62
+ const at = list.indexOf(listener)
63
+ if (at >= 0) list.splice(at, 1)
64
+ }
65
+ /** @internal Deliver `event` (its `type` names the `on<type>` handler too). */
66
+ _dispatch(event: { type: string, [key: string]: any }): void {
67
+ ;(event as any).target = this
68
+ const handler = (this as any)["on" + event.type]
69
+ if (typeof handler === "function") handler.call(this, event)
70
+ const list = this._listeners.get(event.type)
71
+ if (list) for (const listener of list.slice()) listener(event)
72
+ }
73
+ }
74
+
75
+ export interface RTCPeerConnectionIceEvent { type: "icecandidate", candidate: RTCIceCandidate | null, target: RTCPeerConnection }
76
+ export interface RTCTrackEvent { type: "track", track: MediaStreamTrack, streams: MediaStream[], receiver: RTCRtpReceiver, transceiver: RTCRtpTransceiver, target: RTCPeerConnection }
77
+ export interface RTCDataChannelEvent { type: "datachannel", channel: RTCDataChannel, target: RTCPeerConnection }
78
+ export interface RTCMessageEvent { type: "message", data: string | ArrayBuffer, target: RTCDataChannel }
79
+ export interface RTCErrorEvent { type: "error", message: string, target: RTCDataChannel }
80
+ export interface RTCEvent<T extends RTCEventTarget = RTCEventTarget> { type: string, target: T }
81
+
82
+ // ---- the media service: one session, the tracks by id ---------------------------------------------
83
+
84
+ let mediaService: RtcMediaBase | null = null
85
+ /** Every track the app holds, by the host's id: where the host's events find their object. */
86
+ const tracks = new Map<string, MediaStreamTrack>()
87
+ let nextLocalId = 1
88
+
89
+ const media = (): RtcMediaBase => {
90
+ if (mediaService) return mediaService
91
+ if (!RtcMediaBase.supports(MEDIA_NAME)) throw unsupported("mediaDevices", MEDIA_NAME, "service")
92
+ mediaService = new RtcMediaBase()
93
+ mediaService.on("trackEnded", ({ trackId }) => tracks.get(trackId)?._ended())
94
+ mediaService.on("deviceChange", () => mediaDevices._dispatch({ type: "devicechange" }))
95
+ return mediaService
96
+ }
97
+
98
+ /** The track object of a host id: the one the app holds, or a new one for an id first seen now. */
99
+ const trackOf = (info: TrackInfo, remote: boolean): MediaStreamTrack => {
100
+ let track = tracks.get(info.id)
101
+ if (!track) tracks.set(info.id, track = new MediaStreamTrack(info, remote))
102
+ return track
103
+ }
104
+
105
+ /** One track of audio or video, local (`mediaDevices.getUserMedia`) or received (`pc.ontrack`). */
106
+ export class MediaStreamTrack extends RTCEventTarget {
107
+ readonly id: string
108
+ readonly kind: MediaKind
109
+ readonly label: string
110
+ /** @internal */
111
+ readonly _remote: boolean
112
+ private _enabled: boolean
113
+ private _muted: boolean
114
+ private _state: "live" | "ended" = "live"
115
+
116
+ onended: RTCEventListener<RTCEvent<MediaStreamTrack>> | null = null
117
+ onmute: RTCEventListener<RTCEvent<MediaStreamTrack>> | null = null
118
+ onunmute: RTCEventListener<RTCEvent<MediaStreamTrack>> | null = null
119
+
120
+ /** @internal */
121
+ constructor(info: TrackInfo, remote: boolean) {
122
+ super()
123
+ this.id = info.id
124
+ this.kind = info.kind
125
+ this.label = info.label
126
+ this._remote = remote
127
+ this._enabled = info.enabled
128
+ this._muted = info.muted ?? false
129
+ }
130
+
131
+ /** A disabled track sends silence / black frames and stays in every connection. */
132
+ get enabled(): boolean { return this._enabled }
133
+ set enabled(value: boolean) {
134
+ if (this._enabled === value || this._state === "ended") return
135
+ this._enabled = value
136
+ if (!this._remote) media().setTrackEnabled(this.id, value).catch(() => {})
137
+ }
138
+ /** A received track the other side is not sending on. */
139
+ get muted(): boolean { return this._muted }
140
+ get readyState(): "live" | "ended" { return this._state }
141
+
142
+ /** End the track: a local one releases its device (once no track of it is left). */
143
+ stop(): void {
144
+ if (this._state === "ended") return
145
+ this._state = "ended"
146
+ tracks.delete(this.id)
147
+ if (!this._remote && mediaService) mediaService.stopTrack(this.id).catch(() => {})
148
+ }
149
+
150
+ /** Re-aim a local video track: the other camera (`facingMode`), another size or rate. */
151
+ applyConstraints(constraints: VideoConstraints = {}): Promise<void> {
152
+ if (this._remote || this.kind !== "video") return Promise.reject(new Error("applyConstraints: a local video track only"))
153
+ return media().applyConstraints(this.id, constraints)
154
+ }
155
+ /** What a local video track captures with (a promise: the host knows). */
156
+ getSettings(): Promise<TrackSettings> {
157
+ if (this._remote) return Promise.resolve({})
158
+ return media().getTrackSettings(this.id)
159
+ }
160
+ /** The same track object (a track is a handle, not a buffer). */
161
+ clone(): MediaStreamTrack { return this }
162
+
163
+ /** @internal The host reports the track gone. */
164
+ _ended(): void {
165
+ if (this._state === "ended") return
166
+ this._state = "ended"
167
+ tracks.delete(this.id)
168
+ this._dispatch({ type: "ended" })
169
+ }
170
+ /** @internal */
171
+ _setMuted(muted: boolean): void {
172
+ if (this._muted === muted) return
173
+ this._muted = muted
174
+ this._dispatch({ type: muted ? "mute" : "unmute" })
175
+ }
176
+ }
177
+
178
+ /** A group of tracks: what `getUserMedia` resolves with, what a `track` event names, what
179
+ * `addTrack` associates a track with. The id is a label on the wire. */
180
+ export class MediaStream extends RTCEventTarget {
181
+ readonly id: string
182
+ private readonly _tracks: MediaStreamTrack[]
183
+
184
+ onaddtrack: RTCEventListener<{ type: "addtrack", track: MediaStreamTrack, target: MediaStream }> | null = null
185
+ onremovetrack: RTCEventListener<{ type: "removetrack", track: MediaStreamTrack, target: MediaStream }> | null = null
186
+
187
+ constructor(tracks: MediaStreamTrack[] | MediaStream = [], id?: string) {
188
+ super()
189
+ this.id = id ?? `s${nextLocalId++}`
190
+ this._tracks = tracks instanceof MediaStream ? tracks.getTracks() : tracks.slice()
191
+ }
192
+
193
+ /** Whether a track of it is live. */
194
+ get active(): boolean { return this._tracks.some(t => t.readyState === "live") }
195
+ getTracks(): MediaStreamTrack[] { return this._tracks.slice() }
196
+ getAudioTracks(): MediaStreamTrack[] { return this._tracks.filter(t => t.kind === "audio") }
197
+ getVideoTracks(): MediaStreamTrack[] { return this._tracks.filter(t => t.kind === "video") }
198
+ getTrackById(id: string): MediaStreamTrack | null { return this._tracks.find(t => t.id === id) ?? null }
199
+ addTrack(track: MediaStreamTrack): void {
200
+ if (this._tracks.includes(track)) return
201
+ this._tracks.push(track)
202
+ this._dispatch({ type: "addtrack", track })
203
+ }
204
+ removeTrack(track: MediaStreamTrack): void {
205
+ const at = this._tracks.indexOf(track)
206
+ if (at < 0) return
207
+ this._tracks.splice(at, 1)
208
+ this._dispatch({ type: "removetrack", track })
209
+ }
210
+ clone(): MediaStream { return new MediaStream(this._tracks) }
211
+ }
212
+
213
+ /** The constraints of `getUserMedia`, the browser's: `true` for the default device, an object to say which. */
214
+ export interface MediaStreamConstraints {
215
+ audio?: boolean | AudioConstraints
216
+ video?: boolean | VideoConstraints
217
+ }
218
+
219
+ class MediaDevices extends RTCEventTarget {
220
+ ondevicechange: RTCEventListener<RTCEvent<MediaDevices>> | null = null
221
+
222
+ /** Whether this host registered the "rtcMedia" service — the devices. */
223
+ get isSupported(): boolean { return RtcMediaBase.supports(MEDIA_NAME) }
224
+
225
+ /** Capture: asks for the permissions it needs, resolves with a stream of the new tracks. Rejects
226
+ * with "denied" / "notFound" / "failed". */
227
+ async getUserMedia(constraints: MediaStreamConstraints = { audio: true, video: true }): Promise<MediaStream> {
228
+ const wire: { audio?: AudioConstraints, video?: VideoConstraints } = {}
229
+ if (constraints.audio) wire.audio = constraints.audio === true ? {} : constraints.audio
230
+ if (constraints.video) wire.video = constraints.video === true ? {} : constraints.video
231
+ if (!wire.audio && !wire.video) throw new Error("getUserMedia: audio or video must be requested")
232
+ const info = await media().getUserMedia(wire)
233
+ return new MediaStream(info.tracks.map(t => trackOf(t, false)), info.id)
234
+ }
235
+ /** The devices the host can capture with (their labels need a granted permission on the web). */
236
+ enumerateDevices(): Promise<DeviceInfo[]> { return media().enumerateDevices() }
237
+ /** Where the call's audio plays on a phone: the earpiece or the loudspeaker. A no-op on the web. */
238
+ setAudioOutput(output: AudioOutput): Promise<void> { return media().setAudioOutput(output) }
239
+ }
240
+
241
+ /** `navigator.mediaDevices` of the browser: the capture and the device list. Host-optional —
242
+ * `mediaDevices.isSupported` before offering a call. */
243
+ // PURE IIFE so an app that never captures tree-shakes the media side away.
244
+ export const mediaDevices: MediaDevices = /*#__PURE__*/ (() => new MediaDevices())()
245
+
246
+ // ---- the peer connection ---------------------------------------------------------------------------
247
+
248
+ /** The browser's RTCSessionDescription: `{ type, sdp }`, as it is signaled. */
249
+ export class RTCSessionDescription implements SessionDescription {
250
+ readonly type: SdpType
251
+ readonly sdp: string
252
+ constructor(init: SessionDescription) { this.type = init.type; this.sdp = init.sdp }
253
+ toJSON(): SessionDescription { return { type: this.type, sdp: this.sdp } }
254
+ }
255
+
256
+ /** The browser's RTCIceCandidate: what `icecandidate` carries and `addIceCandidate` takes. */
257
+ export class RTCIceCandidate implements IceCandidate {
258
+ readonly candidate: string
259
+ readonly sdpMid: string | null
260
+ readonly sdpMLineIndex: number | null
261
+ readonly usernameFragment: string | undefined
262
+ constructor(init: RTCIceCandidateInit) {
263
+ this.candidate = init.candidate ?? ""
264
+ this.sdpMid = init.sdpMid ?? null
265
+ this.sdpMLineIndex = init.sdpMLineIndex ?? null
266
+ this.usernameFragment = init.usernameFragment
267
+ }
268
+ toJSON(): IceCandidate {
269
+ const json: IceCandidate = { candidate: this.candidate, sdpMid: this.sdpMid, sdpMLineIndex: this.sdpMLineIndex }
270
+ if (this.usernameFragment !== undefined) json.usernameFragment = this.usernameFragment
271
+ return json
272
+ }
273
+ }
274
+ export interface RTCIceCandidateInit { candidate?: string, sdpMid?: string | null, sdpMLineIndex?: number | null, usernameFragment?: string }
275
+
276
+ /** The sending half of a transceiver: the track it sends and its encodings. */
277
+ export class RTCRtpSender {
278
+ /** @internal */
279
+ readonly _id: string
280
+ private readonly _pc: RTCPeerConnection
281
+ private _track: MediaStreamTrack | null
282
+
283
+ /** @internal */
284
+ constructor(pc: RTCPeerConnection, info: SenderInfo) {
285
+ this._pc = pc
286
+ this._id = info.id
287
+ this._track = info.trackId ? tracks.get(info.trackId) ?? null : null
288
+ }
289
+ /** What it sends; null = nothing. */
290
+ get track(): MediaStreamTrack | null { return this._track }
291
+ /** Swap the track without renegotiating; null = send nothing. */
292
+ async replaceTrack(track: MediaStreamTrack | null): Promise<void> {
293
+ await this._pc._s.replaceTrack(this._id, track?.id ?? null)
294
+ this._track = track
295
+ }
296
+ /** The encodings (a promise: the host holds them). */
297
+ getParameters(): Promise<SenderParameters> { return this._pc._s.getSenderParameters(this._id) }
298
+ /** The encodings' active / bitrate / scale. */
299
+ setParameters(parameters: SenderParameters): Promise<void> { return this._pc._s.setSenderParameters(this._id, parameters) }
300
+ /** The stats of this sender. */
301
+ getStats(): Promise<Map<string, any>> { return this._pc.getStats(this._track) }
302
+ /** @internal */
303
+ _update(info: SenderInfo): void { this._track = info.trackId ? tracks.get(info.trackId) ?? this._track : null }
304
+ }
305
+
306
+ /** The receiving half of a transceiver: its track exists from the start, media arrives into it. */
307
+ export class RTCRtpReceiver {
308
+ readonly track: MediaStreamTrack
309
+ private readonly _pc: RTCPeerConnection
310
+ /** @internal */
311
+ constructor(pc: RTCPeerConnection, track: MediaStreamTrack) { this._pc = pc; this.track = track }
312
+ getStats(): Promise<Map<string, any>> { return this._pc.getStats(this.track) }
313
+ }
314
+
315
+ /** One m-line of the session: a sender and a receiver, a direction, a mid once negotiated. */
316
+ export class RTCRtpTransceiver {
317
+ /** @internal */
318
+ readonly _id: string
319
+ readonly sender: RTCRtpSender
320
+ readonly receiver: RTCRtpReceiver
321
+ private readonly _pc: RTCPeerConnection
322
+ private _mid: string | null
323
+ private _direction: TransceiverDirection
324
+ private _currentDirection: TransceiverDirection | null
325
+ private _stopped: boolean
326
+
327
+ /** @internal */
328
+ constructor(pc: RTCPeerConnection, info: TransceiverInfo) {
329
+ this._pc = pc
330
+ this._id = info.id
331
+ this.sender = new RTCRtpSender(pc, info.sender)
332
+ this.receiver = new RTCRtpReceiver(pc, trackOf(info.receiverTrack, true))
333
+ this._mid = info.mid
334
+ this._direction = info.direction
335
+ this._currentDirection = info.currentDirection
336
+ this._stopped = info.stopped
337
+ }
338
+ /** The m-line's id once a description named it. */
339
+ get mid(): string | null { return this._mid }
340
+ /** The direction wanted; setting it takes effect at the next negotiation. */
341
+ get direction(): TransceiverDirection { return this._direction }
342
+ set direction(value: TransceiverDirection) {
343
+ if (this._direction === value) return
344
+ this._direction = value
345
+ this._pc._s.setTransceiverDirection(this._id, value).catch(() => {})
346
+ }
347
+ /** The direction negotiated; null before the first answer. */
348
+ get currentDirection(): TransceiverDirection | null { return this._currentDirection }
349
+ get stopped(): boolean { return this._stopped }
350
+ /** Stop sending and receiving on it; the next negotiation removes the m-line. */
351
+ stop(): void {
352
+ if (this._stopped) return
353
+ this._stopped = true
354
+ this._direction = "stopped"
355
+ this._pc._s.stopTransceiver(this._id).catch(() => {})
356
+ }
357
+ /** @internal */
358
+ _update(info: TransceiverInfo): void {
359
+ this._mid = info.mid
360
+ this._direction = info.direction
361
+ this._currentDirection = info.currentDirection
362
+ this._stopped = info.stopped
363
+ this.sender._update(info.sender)
364
+ }
365
+ }
366
+
367
+ /** A data channel: strings and bytes, ordered and reliable unless said otherwise. */
368
+ export class RTCDataChannel extends RTCEventTarget {
369
+ readonly label: string
370
+ readonly ordered: boolean
371
+ readonly maxPacketLifeTime: number | null
372
+ readonly maxRetransmits: number | null
373
+ readonly protocol: string
374
+ readonly negotiated: boolean
375
+ /** Bytes arrive as an ArrayBuffer (the browser's "blob" is not a thing here). */
376
+ binaryType: "arraybuffer" = "arraybuffer"
377
+ private readonly _pc: RTCPeerConnection
378
+ /** @internal The host's id; null until the host made the channel. */
379
+ _id: string | null
380
+ private _streamId: number | null
381
+ private _state: DataChannelState
382
+ private _threshold = 0
383
+ /** What was sent before the host made the channel. */
384
+ private _queue: (string | Uint8Array)[] | null = null
385
+
386
+ onopen: RTCEventListener<RTCEvent<RTCDataChannel>> | null = null
387
+ onclose: RTCEventListener<RTCEvent<RTCDataChannel>> | null = null
388
+ onerror: RTCEventListener<RTCErrorEvent> | null = null
389
+ onmessage: RTCEventListener<RTCMessageEvent> | null = null
390
+ onbufferedamountlow: RTCEventListener<RTCEvent<RTCDataChannel>> | null = null
391
+
392
+ /** @internal `info` for a channel the host already made (the other side's); `init` + a label for
393
+ * one being made. */
394
+ constructor(pc: RTCPeerConnection, label: string, init: DataChannelInit, info: DataChannelInfo | null) {
395
+ super()
396
+ this._pc = pc
397
+ this.label = info?.label ?? label
398
+ this.ordered = info?.ordered ?? init.ordered ?? true
399
+ this.maxPacketLifeTime = info?.maxPacketLifeTime ?? init.maxPacketLifeTime ?? null
400
+ this.maxRetransmits = info?.maxRetransmits ?? init.maxRetransmits ?? null
401
+ this.protocol = info?.protocol ?? init.protocol ?? ""
402
+ this.negotiated = info?.negotiated ?? init.negotiated ?? false
403
+ this._id = info?.id ?? null
404
+ this._streamId = info?.streamId ?? init.id ?? null
405
+ this._state = info?.readyState ?? "connecting"
406
+ if (!info) this._queue = []
407
+ }
408
+
409
+ /** The SCTP stream id; null until the transport assigned one. */
410
+ get id(): number | null { return this._streamId }
411
+ get readyState(): DataChannelState { return this._state }
412
+ /** Bytes queued and not yet sent (a promise: the host counts them). */
413
+ getBufferedAmount(): Promise<number> { return this._id ? this._pc._s.getBufferedAmount(this._id) : Promise.resolve(0) }
414
+ get bufferedAmountLowThreshold(): number { return this._threshold }
415
+ set bufferedAmountLowThreshold(value: number) {
416
+ this._threshold = value
417
+ if (this._id) this._pc._s.setBufferedAmountLowThreshold(this._id, value).catch(() => {})
418
+ }
419
+
420
+ /** Send a string or bytes. Throws when the channel is closing or closed. */
421
+ send(data: string | ArrayBuffer | ArrayBufferView): void {
422
+ if (this._state === "closing" || this._state === "closed") throw new Error("RTCDataChannel.send: the channel is " + this._state)
423
+ const payload = typeof data === "string" ? data
424
+ : data instanceof ArrayBuffer ? new Uint8Array(data)
425
+ : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
426
+ if (this._queue) { this._queue.push(payload); return }
427
+ this._send(payload)
428
+ }
429
+ private _send(payload: string | Uint8Array): void {
430
+ const id = this._id!
431
+ const sent = typeof payload === "string" ? this._pc._s.sendText(id, payload) : this._pc._s.sendBinary(id, payload)
432
+ sent.catch(e => this._dispatch({ type: "error", message: e instanceof Error ? e.message : String(e) }))
433
+ }
434
+ close(): void {
435
+ if (this._state === "closed" || this._state === "closing") return
436
+ this._state = "closing"
437
+ this._queue = null
438
+ if (this._id) this._pc._s.closeDataChannel(this._id).catch(() => {})
439
+ else this._setState("closed")
440
+ }
441
+
442
+ /** @internal The host made it: flush what was sent meanwhile. */
443
+ _made(info: DataChannelInfo): void {
444
+ this._id = info.id
445
+ this._streamId = info.streamId
446
+ if (this._threshold) this._pc._s.setBufferedAmountLowThreshold(info.id, this._threshold).catch(() => {})
447
+ const queue = this._queue
448
+ this._queue = null
449
+ if (this._state === "closing") { this._pc._s.closeDataChannel(info.id).catch(() => {}); return }
450
+ if (info.readyState !== this._state) this._setState(info.readyState)
451
+ if (queue) for (const payload of queue) this._send(payload)
452
+ }
453
+ /** @internal */
454
+ _setState(state: DataChannelState): void {
455
+ if (this._state === state) return
456
+ this._state = state
457
+ if (state === "open") this._dispatch({ type: "open" })
458
+ else if (state === "closed") this._dispatch({ type: "close" })
459
+ }
460
+ /** @internal */
461
+ _message(text: string | undefined, binary: Uint8Array | undefined): void {
462
+ const data = text !== undefined ? text : binary ? binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength) : ""
463
+ this._dispatch({ type: "message", data })
464
+ }
465
+ }
466
+
467
+ /** `RTCConfiguration` of the browser, with `iceServers[].urls` as one URL or several. */
468
+ export interface RTCConfiguration extends Omit<RtcConfiguration, "iceServers"> {
469
+ iceServers?: { urls: string | string[], username?: string, credential?: string }[]
470
+ }
471
+
472
+ const configurationWire = (config: RTCConfiguration): RtcConfiguration => {
473
+ const { iceServers, ...rest } = config
474
+ const wire: RtcConfiguration = { ...rest }
475
+ if (iceServers) {
476
+ wire.iceServers = iceServers.map(s => {
477
+ const server: IceServer = { urls: Array.isArray(s.urls) ? s.urls : [s.urls] }
478
+ if (s.username !== undefined) server.username = s.username
479
+ if (s.credential !== undefined) server.credential = s.credential
480
+ return server
481
+ })
482
+ }
483
+ return wire
484
+ }
485
+
486
+ const descriptionOf = (d: SessionDescription | RTCSessionDescription | { type: SdpType, sdp?: string }): SessionDescription =>
487
+ ({ type: d.type, sdp: d.sdp ?? "" })
488
+
489
+ /** A connection to one peer: the browser's RTCPeerConnection. The session with the host opens on
490
+ * the first call and closes with `close()`. Signaling — carrying the descriptions and the
491
+ * candidates to the other side — is the app's. */
492
+ export class RTCPeerConnection extends RTCEventTarget {
493
+ /** Whether this host registered the "rtcPeerConnection" service. */
494
+ static get isSupported(): boolean { return RtcPeerConnectionBase.supports(PC_NAME) }
495
+
496
+ /** @internal The session. */
497
+ readonly _s: RtcPeerConnectionBase
498
+ private _configuration: RTCConfiguration
499
+ private _local: RTCSessionDescription | null = null
500
+ private _remote: RTCSessionDescription | null = null
501
+ private _signaling: SignalingState = "stable"
502
+ private _gathering: IceGatheringState = "new"
503
+ private _ice: IceConnectionState = "new"
504
+ private _connection: PeerConnectionState = "new"
505
+ private readonly _transceivers: RTCRtpTransceiver[] = []
506
+ private readonly _channels = new Map<string, RTCDataChannel>()
507
+ private readonly _remoteTracks = new Set<string>()
508
+ /** While a local description is being set, its candidates wait: the browser delivers them
509
+ * after the promise, and the app's signaling expects the description first. */
510
+ private _settingLocal = 0
511
+ private _heldCandidates: (RTCIceCandidate | null)[] = []
512
+ /** After the app's close(): what the host still says of the dying session is not an event (the
513
+ * browser fires none for close()). */
514
+ private _closedByApp = false
515
+
516
+ onnegotiationneeded: RTCEventListener<RTCEvent<RTCPeerConnection>> | null = null
517
+ onicecandidate: RTCEventListener<RTCPeerConnectionIceEvent> | null = null
518
+ onsignalingstatechange: RTCEventListener<RTCEvent<RTCPeerConnection>> | null = null
519
+ onicegatheringstatechange: RTCEventListener<RTCEvent<RTCPeerConnection>> | null = null
520
+ oniceconnectionstatechange: RTCEventListener<RTCEvent<RTCPeerConnection>> | null = null
521
+ onconnectionstatechange: RTCEventListener<RTCEvent<RTCPeerConnection>> | null = null
522
+ ontrack: RTCEventListener<RTCTrackEvent> | null = null
523
+ ondatachannel: RTCEventListener<RTCDataChannelEvent> | null = null
524
+
525
+ constructor(configuration: RTCConfiguration = {}) {
526
+ super()
527
+ if (!RtcPeerConnectionBase.supports(PC_NAME)) throw unsupported("RTCPeerConnection", PC_NAME, "service")
528
+ this._configuration = configuration
529
+ const s = this._s = new RtcPeerConnectionBase(configurationWire(configuration))
530
+ const on: typeof s.on = (event, callback) => s.on(event, ((payload?: any) => { if (!this._closedByApp) (callback as any)(payload) }) as any)
531
+ on("negotiationNeeded", () => this._dispatch({ type: "negotiationneeded" }))
532
+ on("iceCandidate", ({ candidate }) => {
533
+ const ice = candidate ? new RTCIceCandidate(candidate) : null
534
+ if (this._settingLocal > 0) this._heldCandidates.push(ice)
535
+ else this._dispatch({ type: "icecandidate", candidate: ice })
536
+ })
537
+ on("signalingStateChange", ({ state }) => { this._signaling = state; this._dispatch({ type: "signalingstatechange" }) })
538
+ on("iceGatheringStateChange", ({ state }) => { this._gathering = state; this._dispatch({ type: "icegatheringstatechange" }) })
539
+ on("iceConnectionStateChange", ({ state }) => { this._ice = state; this._dispatch({ type: "iceconnectionstatechange" }) })
540
+ on("connectionStateChange", ({ state }) => { this._connection = state; this._dispatch({ type: "connectionstatechange" }) })
541
+ on("track", ({ track, streamIds, transceiverId }) => { this._onTrack(track, streamIds, transceiverId).catch(() => {}) })
542
+ on("trackMute", ({ trackId, muted }) => tracks.get(trackId)?._setMuted(muted))
543
+ on("trackEnded", ({ trackId }) => tracks.get(trackId)?._ended())
544
+ on("dataChannel", info => {
545
+ const channel = new RTCDataChannel(this, info.label, {}, info)
546
+ this._channels.set(info.id, channel)
547
+ this._dispatch({ type: "datachannel", channel })
548
+ })
549
+ on("dataChannelStateChange", ({ id, state }) => {
550
+ const channel = this._channels.get(id)
551
+ if (!channel) return
552
+ channel._setState(state)
553
+ if (state === "closed") this._channels.delete(id)
554
+ })
555
+ on("dataChannelMessage", ({ id, text, binary }) => this._channels.get(id)?._message(text, binary))
556
+ on("dataChannelError", ({ id, message }) => this._channels.get(id)?._dispatch({ type: "error", message }))
557
+ on("dataChannelBufferedAmountLow", ({ id }) => this._channels.get(id)?._dispatch({ type: "bufferedamountlow" }))
558
+ }
559
+
560
+ get localDescription(): RTCSessionDescription | null { return this._local }
561
+ get remoteDescription(): RTCSessionDescription | null { return this._remote }
562
+ get currentLocalDescription(): RTCSessionDescription | null { return this._local }
563
+ get currentRemoteDescription(): RTCSessionDescription | null { return this._remote }
564
+ get signalingState(): SignalingState { return this._signaling }
565
+ get iceGatheringState(): IceGatheringState { return this._gathering }
566
+ get iceConnectionState(): IceConnectionState { return this._ice }
567
+ get connectionState(): PeerConnectionState { return this._connection }
568
+
569
+ async createOffer(options?: OfferOptions): Promise<RTCSessionDescription> {
570
+ return new RTCSessionDescription(await this._s.createOffer(options))
571
+ }
572
+ async createAnswer(): Promise<RTCSessionDescription> {
573
+ return new RTCSessionDescription(await this._s.createAnswer())
574
+ }
575
+ /** Without a description: the implicit form — an answer to a pending remote offer, an offer
576
+ * otherwise. `localDescription` holds what is in force after. */
577
+ async setLocalDescription(description?: SessionDescription | RTCSessionDescription | { type: SdpType, sdp?: string }): Promise<void> {
578
+ this._settingLocal++
579
+ try {
580
+ const inForce = await this._s.setLocalDescription(description ? descriptionOf(description) : undefined)
581
+ this._local = new RTCSessionDescription(inForce)
582
+ await this._syncTransceivers()
583
+ } finally {
584
+ this._settingLocal--
585
+ if (this._settingLocal === 0) {
586
+ const held = this._heldCandidates
587
+ this._heldCandidates = []
588
+ // a task after the promise settled, like the browser's: the caller sends the description first
589
+ if (held.length) setTimeout(() => { for (const ice of held) this._dispatch({ type: "icecandidate", candidate: ice }) }, 0)
590
+ }
591
+ }
592
+ }
593
+ async setRemoteDescription(description: SessionDescription | RTCSessionDescription | { type: SdpType, sdp?: string }): Promise<void> {
594
+ const wire = descriptionOf(description)
595
+ await this._s.setRemoteDescription(wire)
596
+ this._remote = wire.type === "rollback" ? null : new RTCSessionDescription(wire)
597
+ await this._syncTransceivers()
598
+ }
599
+ /** A candidate of the other side; none (or an empty one) = the end of its candidates. */
600
+ addIceCandidate(candidate?: RTCIceCandidateInit | RTCIceCandidate | null): Promise<void> {
601
+ if (!candidate || !candidate.candidate) return this._s.addIceCandidate()
602
+ return this._s.addIceCandidate(new RTCIceCandidate(candidate).toJSON())
603
+ }
604
+ /** The next offer carries new ICE credentials. */
605
+ restartIce(): void { this._s.restartIce().catch(() => {}) }
606
+ getConfiguration(): RTCConfiguration { return this._configuration }
607
+ async setConfiguration(configuration: RTCConfiguration): Promise<void> {
608
+ await this._s.setConfiguration(configurationWire(configuration))
609
+ this._configuration = configuration
610
+ }
611
+
612
+ /** Send a local track, in the streams named (the browser's `addTrack(track, ...streams)`). The
613
+ * sender is known once the host made it: a promise, where the browser answers at once. */
614
+ async addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): Promise<RTCRtpSender> {
615
+ const info = await this._s.addTrack(track.id, streams.map(s => s.id))
616
+ await this._syncTransceivers()
617
+ return this._transceivers.find(t => t.sender._id === info.id)?.sender ?? new RTCRtpSender(this, info)
618
+ }
619
+ async removeTrack(sender: RTCRtpSender): Promise<void> {
620
+ await this._s.removeTrack(sender._id)
621
+ sender._update({ id: sender._id, trackId: null })
622
+ }
623
+ /** Add an m-line: for a kind, or for a track to send on it. A promise, where the browser answers at once. */
624
+ async addTransceiver(trackOrKind: MediaStreamTrack | MediaKind, init: Omit<TransceiverInit, "trackId" | "streamIds"> & { streams?: MediaStream[] } = {}): Promise<RTCRtpTransceiver> {
625
+ const { streams, ...rest } = init
626
+ const wire: TransceiverInit = { ...rest }
627
+ if (typeof trackOrKind !== "string") wire.trackId = trackOrKind.id
628
+ if (streams) wire.streamIds = streams.map(s => s.id)
629
+ const info = await this._s.addTransceiver(typeof trackOrKind === "string" ? trackOrKind : trackOrKind.kind, wire)
630
+ return this._transceiverOf(info)
631
+ }
632
+ /** The transceivers as last synced (after every description, on every `track` event). */
633
+ getTransceivers(): RTCRtpTransceiver[] { return this._transceivers.slice() }
634
+ getSenders(): RTCRtpSender[] { return this._transceivers.map(t => t.sender) }
635
+ getReceivers(): RTCRtpReceiver[] { return this._transceivers.map(t => t.receiver) }
636
+
637
+ /** The RTCStatsReport: a Map of the stats id → the stats object (`type`, `timestamp` in ms, the
638
+ * fields of its type), for the whole connection or one track's senders / receivers. */
639
+ async getStats(selector?: MediaStreamTrack | null): Promise<Map<string, any>> {
640
+ const report = await this._s.getStats(selector?.id)
641
+ const map = new Map<string, any>()
642
+ if (report && typeof report === "object" && !Array.isArray(report)) for (const [id, stats] of Object.entries(report)) map.set(id, stats)
643
+ return map
644
+ }
645
+
646
+ /** Open a data channel. The channel is "connecting" until the host and the other side are ready;
647
+ * what is sent before counts as queued. */
648
+ createDataChannel(label: string, init: DataChannelInit = {}): RTCDataChannel {
649
+ const channel = new RTCDataChannel(this, label, init, null)
650
+ this._s.createDataChannel(label, init).then(
651
+ info => { this._channels.set(info.id, channel); channel._made(info) },
652
+ e => { channel._dispatch({ type: "error", message: e instanceof Error ? e.message : String(e) }); channel._setState("closed") },
653
+ )
654
+ return channel
655
+ }
656
+
657
+ /** Close the connection: the session with the host ends, every received track ends, every
658
+ * channel closes. */
659
+ close(): void {
660
+ if (this._connection === "closed") return
661
+ this._closedByApp = true
662
+ this._connection = "closed"
663
+ this._signaling = "closed"
664
+ this._ice = "closed"
665
+ this._s.close()
666
+ for (const channel of this._channels.values()) channel._setState("closed")
667
+ this._channels.clear()
668
+ for (const id of this._remoteTracks) tracks.get(id)?._ended()
669
+ this._remoteTracks.clear()
670
+ }
671
+
672
+ private _transceiverOf(info: TransceiverInfo): RTCRtpTransceiver {
673
+ let transceiver = this._transceivers.find(t => t._id === info.id)
674
+ if (transceiver) transceiver._update(info)
675
+ else this._transceivers.push(transceiver = new RTCRtpTransceiver(this, info))
676
+ this._remoteTracks.add(info.receiverTrack.id)
677
+ return transceiver
678
+ }
679
+ private async _syncTransceivers(): Promise<void> {
680
+ if (this._connection === "closed") return
681
+ const infos = await this._s.getTransceivers()
682
+ for (const info of infos) this._transceiverOf(info)
683
+ }
684
+ private async _onTrack(info: TrackInfo, streamIds: string[], transceiverId: string): Promise<void> {
685
+ const track = trackOf(info, true)
686
+ this._remoteTracks.add(track.id)
687
+ await this._syncTransceivers()
688
+ let transceiver = this._transceivers.find(t => t._id === transceiverId)
689
+ if (!transceiver) {
690
+ // The host told of a transceiver the sync did not list (closed meanwhile): stand one up for the event.
691
+ transceiver = new RTCRtpTransceiver(this, { id: transceiverId, mid: null, direction: "recvonly", currentDirection: null, sender: { id: "", trackId: null }, receiverTrack: info, stopped: false })
692
+ }
693
+ const streams = streamIds.map(id => new MediaStream([track], id))
694
+ this._dispatch({ type: "track", track, streams, receiver: transceiver.receiver, transceiver })
695
+ }
696
+ }
697
+
698
+ // ---- the picture -----------------------------------------------------------------------------------
699
+
700
+ export interface RTCVideoViewOptions {
701
+ /** The track to draw; none = black. */
702
+ track?: MediaStreamTrack | null
703
+ /** Default "cover". */
704
+ objectFit?: ObjectFit
705
+ /** Flip horizontally — the front camera's preview. Default false. */
706
+ mirror?: boolean
707
+ }
708
+
709
+ /** A tree node that draws one video track: embed it among a screen's children (sized by its
710
+ * styles) or present it fullscreen (`open()`). `track` may change at any time. */
711
+ export interface RTCVideoView extends NativeView {
712
+ /** The track drawn; null = black. */
713
+ track: MediaStreamTrack | null
714
+ objectFit: ObjectFit
715
+ mirror: boolean
716
+ /** `resize`: the first frame, and every change of the picture's size. */
717
+ on<K extends keyof RtcVideoEvents & string>(event: K, callback: Listener<RtcVideoEvents, K>): this
718
+ off<K extends keyof RtcVideoEvents & string>(event: K, callback: Listener<RtcVideoEvents, K>): this
719
+ }
720
+
721
+ class RTCVideoViewElement extends RtcVideoBase {
722
+ private _track: MediaStreamTrack | null
723
+ private _objectFit: ObjectFit
724
+ private _mirror: boolean
725
+
726
+ constructor(options: RTCVideoViewOptions) {
727
+ super({ ...(options.track ? { trackId: options.track.id } : {}), objectFit: options.objectFit ?? "cover", mirror: options.mirror ?? false })
728
+ this._track = options.track ?? null
729
+ this._objectFit = options.objectFit ?? "cover"
730
+ this._mirror = options.mirror ?? false
731
+ }
732
+ get track(): MediaStreamTrack | null { return this._track }
733
+ set track(track: MediaStreamTrack | null) {
734
+ if (this._track === track) return
735
+ this._track = track
736
+ this.setTrack(track?.id ?? null).catch(() => {})
737
+ }
738
+ get objectFit(): ObjectFit { return this._objectFit }
739
+ set objectFit(fit: ObjectFit) {
740
+ if (this._objectFit === fit) return
741
+ this._objectFit = fit
742
+ this.setObjectFit(fit).catch(() => {})
743
+ }
744
+ get mirror(): boolean { return this._mirror }
745
+ set mirror(mirror: boolean) {
746
+ if (this._mirror === mirror) return
747
+ this._mirror = mirror
748
+ this.setMirror(mirror).catch(() => {})
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Create a video view. `RTCVideoView.isSupported` reports whether this host has the capability —
754
+ * check it before offering the feature (a view no host registered mounts nothing).
755
+ */
756
+ // PURE IIFE so an app that never shows a track tree-shakes the view away.
757
+ export const RTCVideoView: {
758
+ (options?: RTCVideoViewOptions): RTCVideoView
759
+ /** Whether this host registered an "rtcVideo" view. */
760
+ readonly isSupported: boolean
761
+ } = /*#__PURE__*/ (() => {
762
+ const factory = (options: RTCVideoViewOptions = {}): RTCVideoView => new RTCVideoViewElement(options) as unknown as RTCVideoView
763
+ Object.defineProperty(factory, "isSupported", { get: (): boolean => RtcVideoBase.supports(VIDEO_NAME) })
764
+ return factory as any
765
+ })()