restream-sdk 0.8.0 → 0.10.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 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.** Calling `goLive()` again for the same `userId`
305
- **supersedes** the previous job (the new one takes over). This prevents duplicate
306
- streams if a streamer double-clicks or reloads mid-stream.
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`/supersede (a newer stream took
525
- over), `0` (network/timeout).
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
- | Starting a 2nd stream stops the first | Expected — **one live stream per streamer**; a new `goLive` supersedes the old. |
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.8.0",
3
+ "version": "0.10.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 };
@@ -400,7 +492,7 @@ export class RestreamStudio extends EventTarget {
400
492
  * Restream channels. Any field omitted falls back to the
401
493
  * default pre-set for this creator on the platform.
402
494
  */
403
- async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta, streamingProvider, layout, screenSessionId, participantOnly } = {}) {
495
+ async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta, streamingProvider, layout, screenSessionId, participantOnly, metadata, preComposited } = {}) {
404
496
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
405
497
  // Idempotency guard: refuse a second goLive while a job is already live. Re-publishing a fresh
406
498
  // session and calling goLive() again on reconnect / screen toggle / collaborator change is the #1
@@ -449,6 +541,13 @@ export class RestreamStudio extends EventTarget {
449
541
  body.screenSessionId = screenId;
450
542
  body.screenTrackName = this._screen?.trackName || "screen";
451
543
  }
544
+ // Arbitrary job metadata + the pre-composited flag. preComposited:true tells the backend this
545
+ // publisher session already contains the partner (client-side composite / chart-canvas), so the
546
+ // per-creator collab reconciler must NOT attach the partner's feed to this job (it would double-
547
+ // composite + flip to sidebyside). Both forms land at restream_jobs.metadata.preComposited.
548
+ const meta = { ...(metadata || {}) };
549
+ if (preComposited) meta.preComposited = true;
550
+ if (Object.keys(meta).length) body.metadata = meta;
452
551
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
453
552
  const job = await this._req("POST", "/api/v1/jobs", body);
454
553
  this.job = job;
@@ -527,6 +626,41 @@ export class RestreamStudio extends EventTarget {
527
626
  return this._streamState;
528
627
  }
529
628
 
629
+ /**
630
+ * Switch a LIVE job's MODE (the new single-job model: one job holds the host's stream; modes
631
+ * change which feeds are composited + their positions — solo / host_screen / 1v1 / 1v1_screen / …).
632
+ * The ingest + RTMP fan-out NEVER restart on a mode change (immutable-job invariant); only the
633
+ * compositor layers/positions change. A guest may arrive later or a different guest swap in
634
+ * without a new job — the compositor is "always ready".
635
+ *
636
+ * On scene_engine='legacy' (default) this drives a brief restartWithFeeds internally; on
637
+ * 'live_v2' it is a true zero-restart sink mutation. The SDK call is identical either way —
638
+ * Stage 2 swaps the engine under you with no client change.
639
+ *
640
+ * @param {string} mode a preset key (solo / host_screen / 1v1 / 1v1_screen / solo_fullscreen_cam)
641
+ * or a per-creator override key configured in the admin
642
+ * @returns {Promise<{ok:boolean, mode:string, applied:boolean, engine:string}>}
643
+ */
644
+ async setMode(mode) {
645
+ if (!mode) throw new Error("mode is required (e.g. 'solo', '1v1', '1v1_screen')");
646
+ const jobId = this.job?.id;
647
+ if (!jobId) throw new Error("Not live — go live before switching modes");
648
+ const r = await this._req("POST", `/api/v1/jobs/${jobId}/mode`, this._withUser({ mode }));
649
+ if (r?.ok) this._emit("mode", { mode, engine: r.engine });
650
+ return r;
651
+ }
652
+
653
+ /**
654
+ * The job's CURRENTLY-active mode (resolved from its live state against the client's presets +
655
+ * per-creator overrides). Returns { mode, template, layoutState }. Read-only.
656
+ */
657
+ async getMode() {
658
+ const jobId = this.job?.id;
659
+ if (!jobId) throw new Error("Not live");
660
+ const r = await this._req("GET", `/api/v1/jobs/${jobId}/mode`, this._withUser({}));
661
+ return r;
662
+ }
663
+
530
664
  /** Last presence/layout state set via setStreamState() (local view). */
531
665
  get streamState() { return this._streamState; }
532
666
 
@@ -542,25 +676,44 @@ export class RestreamStudio extends EventTarget {
542
676
  * go-live if called first, or LIVE mid-stream via the layout transition. Restream-only; not shown
543
677
  * in the client's own player. Must be called from a user gesture (getDisplayMedia requires it).
544
678
  *
545
- * @param {object} [opts] { trackName='screen', audio=false }
679
+ * A CALLER-SUPPLIED track may be passed instead of capturing the OS screen e.g. a
680
+ * `canvas.captureStream()` video track that already contains a pre-composited frame (host cam +
681
+ * guest cam + chart). When `track` is given we skip `getDisplayMedia` entirely (no OS picker, no
682
+ * permission prompt) and publish that track as the layer. The CALLER owns that track's lifecycle:
683
+ * we never stop it (on `stopScreenShare` or teardown), so a single continuous canvas track can
684
+ * survive stop/restart and coin switches with no media blip.
685
+ *
686
+ * NOTE on layout: this publishes as the SCREEN layer, so the backend composites `screen_camera`
687
+ * (screen full-frame + camera PiP). For a PRE-COMPOSITED pass-through feed where you do NOT want a
688
+ * camera PiP, publish the canvas as your PRIMARY track instead (the backend renders `solo` =
689
+ * full-frame, no PiP, mic audio) — see the chart-canvas integration guide.
690
+ *
691
+ * @param {object} [opts] { trackName='screen', audio=false, track?: MediaStreamTrack }
546
692
  * @returns {Promise<{sessionId:string, trackName:string}>}
547
693
  */
548
- async startScreenShare({ trackName = "screen", audio = false } = {}) {
549
- if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
550
- throw new Error("Screen sharing is not supported in this environment");
551
- }
694
+ async startScreenShare({ trackName = "screen", audio = false, track = null } = {}) {
552
695
  if (this._screen) return { sessionId: this._screen.sessionId, trackName: this._screen.trackName };
553
696
 
554
- // 1) Capture the screen.
555
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio });
556
- const track = stream.getVideoTracks()[0];
557
- if (!track) { stream.getTracks().forEach((t) => t.stop()); throw new Error("no screen video track"); }
697
+ // 1) Obtain the video track: caller-supplied (pre-composited, caller-owned) or captured screen.
698
+ let stream = null;
699
+ let videoTrack = track;
700
+ const callerOwnsTrack = !!track;
701
+ if (!callerOwnsTrack) {
702
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
703
+ throw new Error("Screen sharing is not supported in this environment");
704
+ }
705
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio });
706
+ videoTrack = stream.getVideoTracks()[0];
707
+ if (!videoTrack) { stream.getTracks().forEach((t) => t.stop()); throw new Error("no screen video track"); }
708
+ }
709
+ if (!videoTrack) throw new Error("startScreenShare: no video track (pass { track } or enable screen capture)");
710
+ const track_ = videoTrack;
558
711
 
559
712
  try {
560
713
  // 2) ICE servers + peer connection.
561
714
  const { iceServers } = await this._req("GET", "/api/streams/turn-credentials");
562
715
  const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle" });
563
- const transceiver = pc.addTransceiver(track, { direction: "sendonly" });
716
+ const transceiver = pc.addTransceiver(track_, { direction: "sendonly" });
564
717
 
565
718
  // 3) New Cloudflare Realtime session (brokered by our backend — CF creds stay server-side).
566
719
  const session = await this._req("POST", "/api/streams/sessions", {});
@@ -576,17 +729,19 @@ export class RestreamStudio extends EventTarget {
576
729
  });
577
730
  if (res?.sessionDescription) await pc.setRemoteDescription(new RTCSessionDescription(res.sessionDescription));
578
731
 
579
- // Stop-sharing from the browser's own UI must tear us down too.
580
- track.addEventListener("ended", () => { this.stopScreenShare().catch(() => {}); });
732
+ // Stop-sharing from the browser's own UI must tear us down too — but ONLY for a screen we
733
+ // captured. A caller-supplied track's lifecycle belongs to the caller (e.g. a canvas capture
734
+ // that must outlive stop/restart), so we don't bind its 'ended' to our teardown.
735
+ if (!callerOwnsTrack) track_.addEventListener("ended", () => { this.stopScreenShare().catch(() => {}); });
581
736
 
582
- this._screen = { pc, track, stream, sessionId, trackName };
737
+ this._screen = { pc, track: track_, stream, sessionId, trackName, callerOwnsTrack };
583
738
  // Signal the backend WHICH session/track the screen is on so it can pull it as a live layer
584
739
  // and transition the layout server-side (solo → screen_camera, collab → collab_screen).
585
740
  this.setStreamState({ screenShare: true, screenSessionId: sessionId, screenTrackName: trackName });
586
741
  this._emit("screenshare", { active: true, sessionId, trackName });
587
742
  return { sessionId, trackName };
588
743
  } catch (err) {
589
- try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
744
+ if (stream) { try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ } }
590
745
  throw err;
591
746
  }
592
747
  }
@@ -597,8 +752,12 @@ export class RestreamStudio extends EventTarget {
597
752
  if (!s) return;
598
753
  this._screen = null;
599
754
  try { await this._req("POST", `/api/streams/sessions/${s.sessionId}/tracks/close`, { tracks: [{ mid: s.pc?.getTransceivers?.()[0]?.mid, trackName: s.trackName }], force: true }); } catch { /* best-effort */ }
600
- try { s.track.stop(); } catch { /* ignore */ }
601
- try { s.stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
755
+ // Only stop tracks WE created (captured screen). A caller-supplied track (e.g. a continuous
756
+ // canvas capture) is owned by the caller and must keep running across stop/restart + coin switches.
757
+ if (!s.callerOwnsTrack) {
758
+ try { s.track.stop(); } catch { /* ignore */ }
759
+ try { s.stream?.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
760
+ }
602
761
  try { s.pc.close(); } catch { /* ignore */ }
603
762
  // Clear the screen session so the backend reverts the layout (screen_camera → solo,
604
763
  // collab_screen → sidebyside).
@@ -720,20 +879,27 @@ export class RestreamStudio extends EventTarget {
720
879
  * starts only when they accept. Pass your own publisherSessionId + destinations so
721
880
  * the server can begin the merged broadcast on accept (or rely on your active job).
722
881
  *
723
- * collaboratorRestreamOn defaults to TRUE: the invited creator's connected social
724
- * accounts MUST receive the side-by-side composite unless they explicitly opt out
725
- * (pass `collaboratorRestreamOn: false`). This matches the server product intent in
726
- * routes/collab.js and CollabService.createInvite. The bug: defaulting the SDK to
727
- * `false` here sent `false` in the request body, which the server honored, so the
728
- * collaborator's broadcasts/fan-out were silently skipped and the collab frame never
729
- * appeared on the collaborator's own social accounts.
882
+ * collaboratorRestreamOn controls whether the invited creator gets THEIR OWN job +
883
+ * fan-out (their own socials broadcast the composite), and is RESOLVED SERVER-SIDE
884
+ * by default so a single SDK works for both product models:
885
+ * - host/challenger products (Aurealone): only the HOST restreams. The guest is a
886
+ * feed-only participant inside the host's composite and creates NO job. The
887
+ * client's per-server default (collab_guest_restream=false) applies.
888
+ * - symmetric products (fomo.gg): both creators restream their own composite.
889
+ * The client's per-server default is true.
890
+ * Pass `collaboratorRestreamOn: true|false` ONLY to override the per-client default
891
+ * for this specific invite (rare). Omit it (do NOT default it client-side) so the
892
+ * server's per-client rule is the single source of truth.
730
893
  * @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
731
894
  */
732
- async requestCollab({ collaboratorUserId, collaboratorRestreamOn = true, publisherSessionId, destinations, metadata } = {}) {
895
+ async requestCollab({ collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata } = {}) {
733
896
  if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
734
- const invite = await this._req("POST", "/api/v1/collab/request", this._withUser({
735
- collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata,
736
- }));
897
+ const body = this._withUser({ collaboratorUserId, publisherSessionId, destinations, metadata });
898
+ // Only send the flag if the caller EXPLICITLY set it — otherwise omit so the server resolves
899
+ // the per-client default (host-only vs symmetric). Previously this defaulted to TRUE client-side
900
+ // and was sent on every call, forcing a guest fan-out for host-only products → duplicate jobs.
901
+ if (collaboratorRestreamOn === true || collaboratorRestreamOn === false) body.collaboratorRestreamOn = collaboratorRestreamOn;
902
+ const invite = await this._req("POST", "/api/v1/collab/request", body);
737
903
  this._emit("collab-invite", { type: "collab-invite", invite, mine: true });
738
904
  return invite;
739
905
  }
package/types/index.d.ts CHANGED
@@ -104,6 +104,15 @@ export interface GoLiveParams {
104
104
  destinationMeta?: DestinationMeta;
105
105
  /** Initial presence/layout state for the stream (same shape as setStreamState). */
106
106
  layout?: StreamState;
107
+ /** Arbitrary job metadata (stored on the job row, readable by the backend). */
108
+ metadata?: Record<string, unknown>;
109
+ /**
110
+ * Mark this publisher session as PRE-COMPOSITED: it already contains the partner (host + guest
111
+ * composited client-side onto one canvas). The backend renders it full-frame (solo) and the
112
+ * per-creator collab reconciler will NOT attach the partner's feed to this job (which would
113
+ * double-composite + flip to sidebyside). Folded into metadata.preComposited.
114
+ */
115
+ preComposited?: boolean;
107
116
  /** Cloudflare session of a screen-share publish (from startScreenShare) to composite as a
108
117
  * separate layer. Defaults to the SDK's active screen share if omitted. */
109
118
  screenSessionId?: string;
@@ -273,11 +282,19 @@ export class RestreamStudio extends EventTarget {
273
282
  /** Capture the screen and publish it as its OWN Cloudflare session (a separate layer the
274
283
  * backend composites via templates). Pass the returned sessionId to goLive({ screenSessionId }).
275
284
  * Restream-only; must be called from a user gesture. */
276
- startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
285
+ startScreenShare(opts?: { trackName?: string; audio?: boolean; track?: MediaStreamTrack }): Promise<{ sessionId: string; trackName: string }>;
277
286
  /** Stop screen sharing (close the Cloudflare track + peer connection). */
278
287
  stopScreenShare(): Promise<void>;
279
288
  /** Cloudflare session id of the live screen-share publish, or null. */
280
289
  readonly screenSessionId: string | null;
290
+ /** Wait until a published Cloudflare Realtime session is genuinely transmitting media
291
+ * (connectionState 'connected' AND every active sender's outbound-rtp.packetsSent > minPackets),
292
+ * not merely ICE/SDP-connected. Call this RIGHT AFTER your tracks/new/publish helper resolves
293
+ * and BEFORE you signal fan-out (goLive / requestCollab / acceptCollab / setMode) so the
294
+ * restream-side pull never races the publisher's first RTP packets ('Track not found on
295
+ * remote peer'). Pure local getStats polling; no server round-trip. Rejects on timeout /
296
+ * connection failure. New in 0.9.0. */
297
+ waitUntilPublishing(pc: RTCPeerConnection, opts?: { timeoutMs?: number; minPackets?: number; pollMs?: number }): Promise<{ ok: true; senders: number }>;
281
298
  startRealtime(jobId: string): Promise<void>;
282
299
  stopRealtime(): void;
283
300
  // collaborative split-screen
@@ -327,7 +344,7 @@ export interface UseRestream {
327
344
  sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
328
345
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
329
346
  setStreamState(patch: StreamState): StreamState;
330
- startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
347
+ startScreenShare(opts?: { trackName?: string; audio?: boolean; track?: MediaStreamTrack }): Promise<{ sessionId: string; trackName: string }>;
331
348
  stopScreenShare(): Promise<void>;
332
349
  requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
333
350
  acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;