restream-sdk 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restream-sdk",
3
- "version": "0.9.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
@@ -492,7 +492,7 @@ export class RestreamStudio extends EventTarget {
492
492
  * Restream channels. Any field omitted falls back to the
493
493
  * default pre-set for this creator on the platform.
494
494
  */
495
- 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 } = {}) {
496
496
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
497
497
  // Idempotency guard: refuse a second goLive while a job is already live. Re-publishing a fresh
498
498
  // session and calling goLive() again on reconnect / screen toggle / collaborator change is the #1
@@ -541,6 +541,13 @@ export class RestreamStudio extends EventTarget {
541
541
  body.screenSessionId = screenId;
542
542
  body.screenTrackName = this._screen?.trackName || "screen";
543
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;
544
551
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
545
552
  const job = await this._req("POST", "/api/v1/jobs", body);
546
553
  this.job = job;
@@ -669,25 +676,44 @@ export class RestreamStudio extends EventTarget {
669
676
  * go-live if called first, or LIVE mid-stream via the layout transition. Restream-only; not shown
670
677
  * in the client's own player. Must be called from a user gesture (getDisplayMedia requires it).
671
678
  *
672
- * @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 }
673
692
  * @returns {Promise<{sessionId:string, trackName:string}>}
674
693
  */
675
- async startScreenShare({ trackName = "screen", audio = false } = {}) {
676
- if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
677
- throw new Error("Screen sharing is not supported in this environment");
678
- }
694
+ async startScreenShare({ trackName = "screen", audio = false, track = null } = {}) {
679
695
  if (this._screen) return { sessionId: this._screen.sessionId, trackName: this._screen.trackName };
680
696
 
681
- // 1) Capture the screen.
682
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio });
683
- const track = stream.getVideoTracks()[0];
684
- 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;
685
711
 
686
712
  try {
687
713
  // 2) ICE servers + peer connection.
688
714
  const { iceServers } = await this._req("GET", "/api/streams/turn-credentials");
689
715
  const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle" });
690
- const transceiver = pc.addTransceiver(track, { direction: "sendonly" });
716
+ const transceiver = pc.addTransceiver(track_, { direction: "sendonly" });
691
717
 
692
718
  // 3) New Cloudflare Realtime session (brokered by our backend — CF creds stay server-side).
693
719
  const session = await this._req("POST", "/api/streams/sessions", {});
@@ -703,17 +729,19 @@ export class RestreamStudio extends EventTarget {
703
729
  });
704
730
  if (res?.sessionDescription) await pc.setRemoteDescription(new RTCSessionDescription(res.sessionDescription));
705
731
 
706
- // Stop-sharing from the browser's own UI must tear us down too.
707
- 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(() => {}); });
708
736
 
709
- this._screen = { pc, track, stream, sessionId, trackName };
737
+ this._screen = { pc, track: track_, stream, sessionId, trackName, callerOwnsTrack };
710
738
  // Signal the backend WHICH session/track the screen is on so it can pull it as a live layer
711
739
  // and transition the layout server-side (solo → screen_camera, collab → collab_screen).
712
740
  this.setStreamState({ screenShare: true, screenSessionId: sessionId, screenTrackName: trackName });
713
741
  this._emit("screenshare", { active: true, sessionId, trackName });
714
742
  return { sessionId, trackName };
715
743
  } catch (err) {
716
- try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
744
+ if (stream) { try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ } }
717
745
  throw err;
718
746
  }
719
747
  }
@@ -724,8 +752,12 @@ export class RestreamStudio extends EventTarget {
724
752
  if (!s) return;
725
753
  this._screen = null;
726
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 */ }
727
- try { s.track.stop(); } catch { /* ignore */ }
728
- 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
+ }
729
761
  try { s.pc.close(); } catch { /* ignore */ }
730
762
  // Clear the screen session so the backend reverts the layout (screen_camera → solo,
731
763
  // collab_screen → sidebyside).
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,7 +282,7 @@ 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. */
@@ -335,7 +344,7 @@ export interface UseRestream {
335
344
  sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
336
345
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
337
346
  setStreamState(patch: StreamState): StreamState;
338
- 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 }>;
339
348
  stopScreenShare(): Promise<void>;
340
349
  requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
341
350
  acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;