restream-sdk 0.8.0 → 0.9.0
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/README.md +90 -6
- package/package.json +1 -1
- package/src/core.js +145 -11
- package/types/index.d.ts +8 -0
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ Your existing live stream ──► Twitch
|
|
|
33
33
|
- [The `userId` contract & persistence](#the-userid-contract--persistence)
|
|
34
34
|
- [Connecting accounts](#connecting-accounts)
|
|
35
35
|
- [Going live](#going-live)
|
|
36
|
+
- [Screen share & live layout](#screen-share--live-layout)
|
|
37
|
+
- [Collaboration (split-screen)](#collaboration-split-screen)
|
|
36
38
|
- [Live chat & viewers](#live-chat--viewers)
|
|
37
39
|
- [Banners / overlays](#banners--overlays)
|
|
38
40
|
- [Auth modes: publishable key vs. token](#auth-modes-publishable-key-vs-token)
|
|
@@ -72,6 +74,13 @@ To go from zero to live you provide exactly **four** things. Everything else is
|
|
|
72
74
|
You do **not** register OAuth apps, host OAuth callbacks, configure platform
|
|
73
75
|
webhooks, or handle any platform tokens. That's all server-side.
|
|
74
76
|
|
|
77
|
+
> **The golden rule: keep ONE publisher session for the whole stream.** Publish the
|
|
78
|
+
> camera once and reuse that `publisherSessionId`. **Never re-publish (a new session)
|
|
79
|
+
> or call `goLive()` again** on reconnect, screen toggle, or collaborator change — those
|
|
80
|
+
> are *live* changes: use `setStreamState()`, `startScreenShare()`, and `acceptCollab()`.
|
|
81
|
+
> `goLive()` **throws if a stream is already live**; call `stopLive()` to cleanly end one
|
|
82
|
+
> before starting another.
|
|
83
|
+
|
|
75
84
|
---
|
|
76
85
|
|
|
77
86
|
## What you get from your operator
|
|
@@ -301,9 +310,14 @@ await stopLive();
|
|
|
301
310
|
|
|
302
311
|
- **One encode → many destinations.** Restreaming to 2 platforms or 5 costs the
|
|
303
312
|
same on the source; it's a single encode fanned out.
|
|
304
|
-
- **One live stream per streamer.**
|
|
305
|
-
|
|
306
|
-
|
|
313
|
+
- **One live stream per streamer.** `goLive()` **throws** if a job is already live for
|
|
314
|
+
this streamer — guarding against accidental double-clicks / re-go-lives (the #1 cause
|
|
315
|
+
of duplicate + stale jobs). Call `stopLive()` first to intentionally restart. Screen /
|
|
316
|
+
collab / layout are *live* changes — use `setStreamState()`, `startScreenShare()`,
|
|
317
|
+
`acceptCollab()`, never a second `goLive()`.
|
|
318
|
+
- **`participantOnly: true`** goes live as a compositing-only participant — a collab
|
|
319
|
+
guest with **no** socials: the job runs (pullable into a host's composite) but fans
|
|
320
|
+
out nowhere. Pass no `destinations`.
|
|
307
321
|
- **Watch links.** Once broadcasts are created, `job.broadcasts` carries
|
|
308
322
|
`{ platform, watchUrl }` for each destination — handy for "Watch on Twitch ↗".
|
|
309
323
|
- **`arm({ destinations })`** pre-selects destinations so you can fire `goLive`
|
|
@@ -312,6 +326,62 @@ await stopLive();
|
|
|
312
326
|
|
|
313
327
|
---
|
|
314
328
|
|
|
329
|
+
## Screen share & live layout
|
|
330
|
+
|
|
331
|
+
Screen share is a **separate layer** on your existing stream — never a re-publish or a new `goLive`:
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
await startScreenShare(); // publishes the screen session + composites it (solo → screen_camera, collab → collab_screen)
|
|
335
|
+
await stopScreenShare(); // reverts the layout
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Push presence/layout live (debounced, best-effort) with `setStreamState`:
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
setStreamState({ screenShare: true, camera: true, collab: false,
|
|
342
|
+
cameraPosition: "bottom-right", cameraSize: "small" });
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Collaboration (split-screen)
|
|
348
|
+
|
|
349
|
+
Two creators appear side-by-side. Your operator picks the model per client:
|
|
350
|
+
|
|
351
|
+
**Per-creator (default)** — each creator keeps their **own** live stream; the composite is fanned to **both** creators' own socials:
|
|
352
|
+
|
|
353
|
+
```js
|
|
354
|
+
// A (host, already live) invites B
|
|
355
|
+
await requestCollab({ collaboratorUserId, publisherSessionId, destinations: ["twitch", "kick"] });
|
|
356
|
+
// B (already live) accepts with their live session
|
|
357
|
+
await acceptCollab(inviteId, { publisherSessionId });
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
When a collaborator leaves or drops, each still-live creator **automatically reverts to solo** — no stream stops. Send `setStreamState({ collab: false })` for an immediate clean transition.
|
|
361
|
+
|
|
362
|
+
**Merged (host + challenger)** — only the **host** creates the stream; the challenger joins as a **feed**, with **no job of their own**:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
// Challenger: publish a camera, then join — NO goLive:
|
|
366
|
+
await acceptCollab(inviteId, { publisherSessionId }); // participantOnly guest
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- A **new** challenger calling `acceptCollab` while the host is live **swaps** the current one out — **live**, on the host's same session (the host never re-publishes).
|
|
370
|
+
- Live swap is **feed-only**: a swapped-in challenger must have no own socials. An audience-carrying swap is refused — end the collab and start fresh.
|
|
371
|
+
|
|
372
|
+
**Realtime** — open the collab stream to react to invites/starts/ends live:
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
studio.on("collab-composite", (e) => enterCollabUI(e.job)); // you're in the composite
|
|
376
|
+
studio.on("collab-reassigned", (e) => followJob(e.job)); // your job changed (reverted to solo / swapped out)
|
|
377
|
+
studio.on("collab-ended", () => exitCollabUI());
|
|
378
|
+
await startCollabRealtime();
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The SDK **auto-adopts** the new `job.id` on `collab-composite` / `collab-reassigned`, so your `job` state follows the right stream automatically.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
315
385
|
## Live chat & viewers
|
|
316
386
|
|
|
317
387
|
```js
|
|
@@ -426,6 +496,13 @@ defaults to `15000`. On mount it auto-loads connections; `job.status` is polled
|
|
|
426
496
|
| `refreshJob()` | `Promise<Job>` | re-fetch job status |
|
|
427
497
|
| `sendChat(message, platforms?)` | `Promise` | outbound chat fan-out |
|
|
428
498
|
| `updateOverlay(opts)` | `Promise` | live banner update |
|
|
499
|
+
| `setStreamState(patch)` | — | push live presence/layout (screen/camera/collab, position/size) |
|
|
500
|
+
| `startScreenShare()` / `stopScreenShare()` | `Promise` | screen as a live layer (no re-publish) |
|
|
501
|
+
| `requestCollab(opts)` / `acceptCollab(id, opts)` | `Promise` | invite / join a collab — see [Collaboration](#collaboration-split-screen) |
|
|
502
|
+
| `declineCollab(id)` / `revokeCollab(id)` / `endCollab(id?)` | `Promise` | decline / cancel / end a collab |
|
|
503
|
+
| `setCollabRestream(id, on)` | `Promise` | toggle a guest's own-socials fan-out |
|
|
504
|
+
| `listCollabInvites()` · `collabInvites` | `Promise` · state | sent + received invites |
|
|
505
|
+
| `startCollabRealtime()` / `stopCollabRealtime()` | `Promise` | collab event stream (SSE) |
|
|
429
506
|
| `studio` | `RestreamStudio` | the underlying controller |
|
|
430
507
|
|
|
431
508
|
### Convenience hooks
|
|
@@ -483,6 +560,12 @@ interface Viewers {
|
|
|
483
560
|
}
|
|
484
561
|
```
|
|
485
562
|
|
|
563
|
+
**Events** — subscribe with `studio.on(type, cb)` (the hook subscribes for you):
|
|
564
|
+
`connections` · `job` · `viewers` · `comment` · `comments` · `error` · `state` ·
|
|
565
|
+
`screenshare` · and collab: `collab-invite` · `collab-accepted` · `collab-declined` ·
|
|
566
|
+
`collab-revoked` · `collab-ended` · `collab-expired` · `collab-composite` ·
|
|
567
|
+
**`collab-reassigned`** (your job id changed — the SDK adopts it) · `collab-snapshot`.
|
|
568
|
+
|
|
486
569
|
---
|
|
487
570
|
|
|
488
571
|
## Scopes
|
|
@@ -521,8 +604,9 @@ A `403 "Missing required scope"` means your key needs that scope added.
|
|
|
521
604
|
catch (e) { if (e.status === 403) showUpgrade(); else toast(e.message); }
|
|
522
605
|
```
|
|
523
606
|
- Common statuses: `401` (bad/expired credential — token mode re-mints automatically),
|
|
524
|
-
`403` (origin not allowed, or missing scope), `409
|
|
525
|
-
|
|
607
|
+
`403` (origin not allowed, or missing scope), `409` (a stream is already live for this
|
|
608
|
+
streamer, or a duplicate collab), `0` (network/timeout). Note: `goLive()` also **throws
|
|
609
|
+
client-side** if a job is already live — call `stopLive()` first.
|
|
526
610
|
- Realtime is resilient: transient SSE drops reconnect automatically and chat
|
|
527
611
|
history de-dupes on replay; job-status polling retries on transient failures.
|
|
528
612
|
|
|
@@ -538,7 +622,7 @@ A `403 "Missing required scope"` means your key needs that scope added.
|
|
|
538
622
|
| Connections empty after refresh | You passed a **different `userId`** — it must be stable per streamer. |
|
|
539
623
|
| Job goes `running` but nothing on the platforms | The `publisherSessionId` must be a **live** session actively sending media when you `goLive()`. |
|
|
540
624
|
| Worker seems to wait / no audio | Your session has no audio track — pass `trackNames: ["video"]`. |
|
|
541
|
-
|
|
|
625
|
+
| `goLive()` throws "a stream is already live" | Expected (0.7.0+) — **one live stream per streamer**. Call `stopLive()` first; use `setStreamState()` / `startScreenShare()` / `acceptCollab()` for live changes (don't re-`goLive`). |
|
|
542
626
|
| `Missing required scope` (403) | Key needs that scope (`connect` / `restream` / `read` / `chat:write`). |
|
|
543
627
|
|
|
544
628
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restream-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers, collaborative split-screen) with zero client backend.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/core.js
CHANGED
|
@@ -102,6 +102,98 @@ export class RestreamStudio extends EventTarget {
|
|
|
102
102
|
return { Authorization: `Bearer ${await this._getToken()}` };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Wait until a published Cloudflare Realtime session is GENUINELY transmitting media — not merely
|
|
107
|
+
* ICE/SDP-connected. This closes the "the worker pulled before the publisher was actually sending"
|
|
108
|
+
* race: a CF `tracks/new` success returns the moment the SDP answer is accepted, but Cloudflare
|
|
109
|
+
* only registers a "live" remote peer once the publisher's RTCPeerConnection hits connectionState
|
|
110
|
+
* 'connected' AND every active sender starts emitting RTP packets (outbound-rtp.packetsSent > 0).
|
|
111
|
+
*
|
|
112
|
+
* Call this RIGHT AFTER your tracks/new / publish helper resolves and BEFORE you fan-out (goLive,
|
|
113
|
+
* requestCollab, acceptCollab, setMode). The restream worker pulls immediately at that point; if
|
|
114
|
+
* you signal before media is flowing, the pull fails transitently ("Track not found on remote
|
|
115
|
+
* peer") and the composite can drop.
|
|
116
|
+
*
|
|
117
|
+
* Resolves when BOTH are true (or rejects on timeout). Pure local getStats polling — no server
|
|
118
|
+
* round-trip. Picks senders dynamically so a replaceTrack mid-wait is tolerated.
|
|
119
|
+
*
|
|
120
|
+
* @param {RTCPeerConnection} pc the publisher PeerConnection (from your publish helper)
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {number} [opts.timeoutMs=8000] hard cap; rejects with a clear error if media never flows
|
|
123
|
+
* @param {number} [opts.minPackets=1] per-sender packet count considered "transmitting"
|
|
124
|
+
* @param {number} [opts.pollMs=250] getStats interval
|
|
125
|
+
* @returns {Promise<{ok:true, senders:number}>}
|
|
126
|
+
* @throws {Error} on timeout or if the connection dies during the wait
|
|
127
|
+
*/
|
|
128
|
+
async waitUntilPublishing(pc, { timeoutMs = 8000, minPackets = 1, pollMs = 250 } = {}) {
|
|
129
|
+
if (!pc || typeof pc.getStats !== "function") {
|
|
130
|
+
throw new Error("waitUntilPublishing: an RTCPeerConnection is required");
|
|
131
|
+
}
|
|
132
|
+
const deadline = Date.now() + Math.max(500, timeoutMs);
|
|
133
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
134
|
+
|
|
135
|
+
// A dead/failing connection is a terminal failure — surface it instead of waiting out the timer.
|
|
136
|
+
let dead = false;
|
|
137
|
+
const onFail = () => { dead = true; };
|
|
138
|
+
const wrap = () => {
|
|
139
|
+
try { pc.addEventListener("connectionstatechange", onFail); } catch {}
|
|
140
|
+
};
|
|
141
|
+
const unwrap = () => {
|
|
142
|
+
try { pc.removeEventListener("connectionstatechange", onFail); } catch {}
|
|
143
|
+
};
|
|
144
|
+
wrap();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
if (dead || pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
149
|
+
throw new Error(`waitUntilPublishing: connection ${pc.connectionState} before media flowed`);
|
|
150
|
+
}
|
|
151
|
+
const connected = pc.connectionState === "connected" || pc.iceConnectionState === "connected";
|
|
152
|
+
let transmitting = connected;
|
|
153
|
+
let senderCount = 0;
|
|
154
|
+
if (connected) {
|
|
155
|
+
try {
|
|
156
|
+
const stats = await pc.getStats();
|
|
157
|
+
// Map each sender to whether its outbound-rtp shows real packets. Every sender must pass,
|
|
158
|
+
// so a single stalled track holds the resolve (that's the whole point — the worker pulls
|
|
159
|
+
// every track and any one missing makes CF reject the pull).
|
|
160
|
+
const senders = pc.getSenders ? pc.getSenders() : [];
|
|
161
|
+
for (const s of senders) {
|
|
162
|
+
if (!s.track) continue; // a sender without a live track (e.g. stopped) doesn't count
|
|
163
|
+
senderCount++;
|
|
164
|
+
let trackReport = null;
|
|
165
|
+
stats.forEach((report) => {
|
|
166
|
+
if (report.type === "outbound-rtp" && report.track === s.track.id && !report.isReceiving) {
|
|
167
|
+
trackReport = report;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Some UA variants expose the sender via report trackIdentifier / mediaSource — cover both.
|
|
171
|
+
if (!trackReport) {
|
|
172
|
+
stats.forEach((report) => {
|
|
173
|
+
if (report.type === "outbound-rtp" && (report.trackIdentifier === s.track.id || (report.id && s.track.id && report.track === s.track.id))) {
|
|
174
|
+
trackReport = report;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (!trackReport || !Number.isFinite(trackReport.packetsSent) || trackReport.packetsSent < minPackets) {
|
|
179
|
+
transmitting = false;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (senderCount === 0) transmitting = false; // nothing to transmit yet
|
|
184
|
+
} catch {
|
|
185
|
+
transmitting = false; // transient getStats failure — keep polling
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (transmitting && senderCount > 0) return { ok: true, senders: senderCount };
|
|
189
|
+
await sleep(pollMs);
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`waitUntilPublishing: timed out after ${timeoutMs}ms (connectionState=${pc.connectionState}) — media never flowed`);
|
|
192
|
+
} finally {
|
|
193
|
+
unwrap();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
105
197
|
async _authQuery() {
|
|
106
198
|
if (this.publishableKey) {
|
|
107
199
|
const q = { pk: this.publishableKey };
|
|
@@ -527,6 +619,41 @@ export class RestreamStudio extends EventTarget {
|
|
|
527
619
|
return this._streamState;
|
|
528
620
|
}
|
|
529
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Switch a LIVE job's MODE (the new single-job model: one job holds the host's stream; modes
|
|
624
|
+
* change which feeds are composited + their positions — solo / host_screen / 1v1 / 1v1_screen / …).
|
|
625
|
+
* The ingest + RTMP fan-out NEVER restart on a mode change (immutable-job invariant); only the
|
|
626
|
+
* compositor layers/positions change. A guest may arrive later or a different guest swap in
|
|
627
|
+
* without a new job — the compositor is "always ready".
|
|
628
|
+
*
|
|
629
|
+
* On scene_engine='legacy' (default) this drives a brief restartWithFeeds internally; on
|
|
630
|
+
* 'live_v2' it is a true zero-restart sink mutation. The SDK call is identical either way —
|
|
631
|
+
* Stage 2 swaps the engine under you with no client change.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} mode a preset key (solo / host_screen / 1v1 / 1v1_screen / solo_fullscreen_cam)
|
|
634
|
+
* or a per-creator override key configured in the admin
|
|
635
|
+
* @returns {Promise<{ok:boolean, mode:string, applied:boolean, engine:string}>}
|
|
636
|
+
*/
|
|
637
|
+
async setMode(mode) {
|
|
638
|
+
if (!mode) throw new Error("mode is required (e.g. 'solo', '1v1', '1v1_screen')");
|
|
639
|
+
const jobId = this.job?.id;
|
|
640
|
+
if (!jobId) throw new Error("Not live — go live before switching modes");
|
|
641
|
+
const r = await this._req("POST", `/api/v1/jobs/${jobId}/mode`, this._withUser({ mode }));
|
|
642
|
+
if (r?.ok) this._emit("mode", { mode, engine: r.engine });
|
|
643
|
+
return r;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The job's CURRENTLY-active mode (resolved from its live state against the client's presets +
|
|
648
|
+
* per-creator overrides). Returns { mode, template, layoutState }. Read-only.
|
|
649
|
+
*/
|
|
650
|
+
async getMode() {
|
|
651
|
+
const jobId = this.job?.id;
|
|
652
|
+
if (!jobId) throw new Error("Not live");
|
|
653
|
+
const r = await this._req("GET", `/api/v1/jobs/${jobId}/mode`, this._withUser({}));
|
|
654
|
+
return r;
|
|
655
|
+
}
|
|
656
|
+
|
|
530
657
|
/** Last presence/layout state set via setStreamState() (local view). */
|
|
531
658
|
get streamState() { return this._streamState; }
|
|
532
659
|
|
|
@@ -720,20 +847,27 @@ export class RestreamStudio extends EventTarget {
|
|
|
720
847
|
* starts only when they accept. Pass your own publisherSessionId + destinations so
|
|
721
848
|
* the server can begin the merged broadcast on accept (or rely on your active job).
|
|
722
849
|
*
|
|
723
|
-
* collaboratorRestreamOn
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
850
|
+
* collaboratorRestreamOn controls whether the invited creator gets THEIR OWN job +
|
|
851
|
+
* fan-out (their own socials broadcast the composite), and is RESOLVED SERVER-SIDE
|
|
852
|
+
* by default so a single SDK works for both product models:
|
|
853
|
+
* - host/challenger products (Aurealone): only the HOST restreams. The guest is a
|
|
854
|
+
* feed-only participant inside the host's composite and creates NO job. The
|
|
855
|
+
* client's per-server default (collab_guest_restream=false) applies.
|
|
856
|
+
* - symmetric products (fomo.gg): both creators restream their own composite.
|
|
857
|
+
* The client's per-server default is true.
|
|
858
|
+
* Pass `collaboratorRestreamOn: true|false` ONLY to override the per-client default
|
|
859
|
+
* for this specific invite (rare). Omit it (do NOT default it client-side) so the
|
|
860
|
+
* server's per-client rule is the single source of truth.
|
|
730
861
|
* @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
|
|
731
862
|
*/
|
|
732
|
-
async requestCollab({ collaboratorUserId, collaboratorRestreamOn
|
|
863
|
+
async requestCollab({ collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata } = {}) {
|
|
733
864
|
if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
865
|
+
const body = this._withUser({ collaboratorUserId, publisherSessionId, destinations, metadata });
|
|
866
|
+
// Only send the flag if the caller EXPLICITLY set it — otherwise omit so the server resolves
|
|
867
|
+
// the per-client default (host-only vs symmetric). Previously this defaulted to TRUE client-side
|
|
868
|
+
// and was sent on every call, forcing a guest fan-out for host-only products → duplicate jobs.
|
|
869
|
+
if (collaboratorRestreamOn === true || collaboratorRestreamOn === false) body.collaboratorRestreamOn = collaboratorRestreamOn;
|
|
870
|
+
const invite = await this._req("POST", "/api/v1/collab/request", body);
|
|
737
871
|
this._emit("collab-invite", { type: "collab-invite", invite, mine: true });
|
|
738
872
|
return invite;
|
|
739
873
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -278,6 +278,14 @@ export class RestreamStudio extends EventTarget {
|
|
|
278
278
|
stopScreenShare(): Promise<void>;
|
|
279
279
|
/** Cloudflare session id of the live screen-share publish, or null. */
|
|
280
280
|
readonly screenSessionId: string | null;
|
|
281
|
+
/** Wait until a published Cloudflare Realtime session is genuinely transmitting media
|
|
282
|
+
* (connectionState 'connected' AND every active sender's outbound-rtp.packetsSent > minPackets),
|
|
283
|
+
* not merely ICE/SDP-connected. Call this RIGHT AFTER your tracks/new/publish helper resolves
|
|
284
|
+
* and BEFORE you signal fan-out (goLive / requestCollab / acceptCollab / setMode) so the
|
|
285
|
+
* restream-side pull never races the publisher's first RTP packets ('Track not found on
|
|
286
|
+
* remote peer'). Pure local getStats polling; no server round-trip. Rejects on timeout /
|
|
287
|
+
* connection failure. New in 0.9.0. */
|
|
288
|
+
waitUntilPublishing(pc: RTCPeerConnection, opts?: { timeoutMs?: number; minPackets?: number; pollMs?: number }): Promise<{ ok: true; senders: number }>;
|
|
281
289
|
startRealtime(jobId: string): Promise<void>;
|
|
282
290
|
stopRealtime(): void;
|
|
283
291
|
// collaborative split-screen
|