restream-sdk 0.4.2 → 0.6.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.4.2",
3
+ "version": "0.6.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
@@ -52,6 +52,13 @@ export class RestreamStudio extends EventTarget {
52
52
  this._pendingState = null;
53
53
  this._stateTimer = null;
54
54
 
55
+ // Screen-share: the SDK captures the screen (getDisplayMedia) and publishes it as its OWN
56
+ // Cloudflare Realtime session (separate from the camera), so our backend composites it as a
57
+ // distinct LAYER (screen + camera PiP) server-side via the template system. Restream-only —
58
+ // fomo.gg's own player never renders it. Null until startScreenShare(); holds { pc, sessionId,
59
+ // trackName, track, stream } while active.
60
+ this._screen = null;
61
+
55
62
  this._token = null;
56
63
  this._tokenExp = 0;
57
64
  this._es = null;
@@ -393,7 +400,7 @@ export class RestreamStudio extends EventTarget {
393
400
  * Restream channels. Any field omitted falls back to the
394
401
  * default pre-set for this creator on the platform.
395
402
  */
396
- async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta, streamingProvider, layout } = {}) {
403
+ async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta, streamingProvider, layout, screenSessionId, participantOnly } = {}) {
397
404
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
398
405
  const body = {
399
406
  publisherSessionId,
@@ -401,6 +408,10 @@ export class RestreamStudio extends EventTarget {
401
408
  overlay,
402
409
  recording,
403
410
  };
411
+ // Compositing-only participant (e.g. a collab challenger with no socials): go live with NO
412
+ // destinations. The job reaches 'running' — discoverable as an activeSoloJob so a per-creator
413
+ // collab can composite its camera — but fans out nowhere. Only forwarded when true.
414
+ if (participantOnly) body.participantOnly = true;
404
415
  if (trackNames && trackNames.length) body.trackNames = trackNames;
405
416
  // Per-platform selection for the "restream" fan-out bridge (channel ids or platform names).
406
417
  if (restreamChannels && restreamChannels.length) body.restreamChannels = restreamChannels;
@@ -421,6 +432,14 @@ export class RestreamStudio extends EventTarget {
421
432
  if (streamingProvider && typeof streamingProvider === "object" && Object.keys(streamingProvider).length) {
422
433
  body.streamingProvider = streamingProvider;
423
434
  }
435
+ // Screen-share as a separate LAYER: pass the screen-publish session + its track name so the
436
+ // backend pulls the RIGHT track (the SDK publishes it as "screen", not "video") and composites
437
+ // screen + camera per the template. Defaults to the SDK's active screen share.
438
+ const screenId = screenSessionId || this.screenSessionId;
439
+ if (screenId) {
440
+ body.screenSessionId = screenId;
441
+ body.screenTrackName = this._screen?.trackName || "screen";
442
+ }
424
443
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
425
444
  const job = await this._req("POST", "/api/v1/jobs", body);
426
445
  this.job = job;
@@ -502,6 +521,93 @@ export class RestreamStudio extends EventTarget {
502
521
  /** Last presence/layout state set via setStreamState() (local view). */
503
522
  get streamState() { return this._streamState; }
504
523
 
524
+ /** The Cloudflare session id of the live screen-share publish (or null). Pass this to
525
+ * goLive({ screenSessionId }) so the backend pulls the screen as a separate layer. */
526
+ get screenSessionId() { return this._screen?.sessionId || null; }
527
+
528
+ /**
529
+ * Start screen sharing: capture the screen and publish it as its OWN Cloudflare Realtime
530
+ * session (a separate LAYER from the camera). Returns { sessionId, trackName }. Also calls
531
+ * setStreamState({ screenShare: true, screenSessionId, screenTrackName }) for you, so the backend
532
+ * composites screen + camera server-side (solo → screen_camera, collab → collab_screen) — at
533
+ * go-live if called first, or LIVE mid-stream via the layout transition. Restream-only; not shown
534
+ * in the client's own player. Must be called from a user gesture (getDisplayMedia requires it).
535
+ *
536
+ * @param {object} [opts] { trackName='screen', audio=false }
537
+ * @returns {Promise<{sessionId:string, trackName:string}>}
538
+ */
539
+ async startScreenShare({ trackName = "screen", audio = false } = {}) {
540
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
541
+ throw new Error("Screen sharing is not supported in this environment");
542
+ }
543
+ if (this._screen) return { sessionId: this._screen.sessionId, trackName: this._screen.trackName };
544
+
545
+ // 1) Capture the screen.
546
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio });
547
+ const track = stream.getVideoTracks()[0];
548
+ if (!track) { stream.getTracks().forEach((t) => t.stop()); throw new Error("no screen video track"); }
549
+
550
+ try {
551
+ // 2) ICE servers + peer connection.
552
+ const { iceServers } = await this._req("GET", "/api/streams/turn-credentials");
553
+ const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle" });
554
+ const transceiver = pc.addTransceiver(track, { direction: "sendonly" });
555
+
556
+ // 3) New Cloudflare Realtime session (brokered by our backend — CF creds stay server-side).
557
+ const session = await this._req("POST", "/api/streams/sessions", {});
558
+ const sessionId = session.sessionId;
559
+ if (!sessionId) throw new Error("no sessionId from /streams/sessions");
560
+
561
+ // 4) Offer, wait for ICE gathering (CF wants the full offer), then push the LOCAL track.
562
+ await pc.setLocalDescription(await pc.createOffer());
563
+ await this._waitIceComplete(pc);
564
+ const res = await this._req("POST", `/api/streams/sessions/${sessionId}/tracks/new`, {
565
+ sessionDescription: { type: "offer", sdp: pc.localDescription.sdp },
566
+ tracks: [{ location: "local", mid: transceiver.mid, trackName }],
567
+ });
568
+ if (res?.sessionDescription) await pc.setRemoteDescription(new RTCSessionDescription(res.sessionDescription));
569
+
570
+ // Stop-sharing from the browser's own UI must tear us down too.
571
+ track.addEventListener("ended", () => { this.stopScreenShare().catch(() => {}); });
572
+
573
+ this._screen = { pc, track, stream, sessionId, trackName };
574
+ // Signal the backend WHICH session/track the screen is on so it can pull it as a live layer
575
+ // and transition the layout server-side (solo → screen_camera, collab → collab_screen).
576
+ this.setStreamState({ screenShare: true, screenSessionId: sessionId, screenTrackName: trackName });
577
+ this._emit("screenshare", { active: true, sessionId, trackName });
578
+ return { sessionId, trackName };
579
+ } catch (err) {
580
+ try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
581
+ throw err;
582
+ }
583
+ }
584
+
585
+ /** Stop screen sharing: close the Cloudflare track + tear down the peer connection. */
586
+ async stopScreenShare() {
587
+ const s = this._screen;
588
+ if (!s) return;
589
+ this._screen = null;
590
+ 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 */ }
591
+ try { s.track.stop(); } catch { /* ignore */ }
592
+ try { s.stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
593
+ try { s.pc.close(); } catch { /* ignore */ }
594
+ // Clear the screen session so the backend reverts the layout (screen_camera → solo,
595
+ // collab_screen → sidebyside).
596
+ this.setStreamState({ screenShare: false, screenSessionId: null, screenTrackName: null });
597
+ this._emit("screenshare", { active: false });
598
+ }
599
+
600
+ // Resolve once ICE gathering completes (or after a short timeout) so the offer carries candidates.
601
+ _waitIceComplete(pc, timeoutMs = 3000) {
602
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
603
+ return new Promise((resolve) => {
604
+ const done = () => { pc.removeEventListener("icegatheringstatechange", check); clearTimeout(t); resolve(); };
605
+ const check = () => { if (pc.iceGatheringState === "complete") done(); };
606
+ const t = setTimeout(done, timeoutMs);
607
+ pc.addEventListener("icegatheringstatechange", check);
608
+ });
609
+ }
610
+
505
611
  // ---- realtime (chat SSE + job-status/viewer polling) ----
506
612
  async startRealtime(jobId) {
507
613
  this.stopRealtime();
package/src/react.js CHANGED
@@ -58,6 +58,8 @@ export function useRestream(opts) {
58
58
  sendChat: useCallback((m, p) => studio.sendChat(m, p), [studio]),
59
59
  updateOverlay: useCallback((o) => studio.updateOverlay(o), [studio]),
60
60
  setStreamState: useCallback((s) => studio.setStreamState(s), [studio]),
61
+ startScreenShare: useCallback((o) => studio.startScreenShare(o), [studio]),
62
+ stopScreenShare: useCallback(() => studio.stopScreenShare(), [studio]),
61
63
  // collaborative split-screen
62
64
  requestCollab: useCallback((o) => studio.requestCollab(o), [studio]),
63
65
  acceptCollab: useCallback((id, o) => studio.acceptCollab(id, o), [studio]),
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
+ /** Cloudflare session of a screen-share publish (from startScreenShare) to composite as a
108
+ * separate layer. Defaults to the SDK's active screen share if omitted. */
109
+ screenSessionId?: string;
110
+ /**
111
+ * Compositing-only participant: go live with NO destinations. The job reaches `running` (so it's
112
+ * discoverable as an active solo job for a per-creator collab to composite its camera) but fans
113
+ * out nowhere. For a collab participant with no socials of their own (e.g. a challenger).
114
+ */
115
+ participantOnly?: boolean;
107
116
  }
108
117
 
109
118
  export interface StreamMeta {
@@ -131,6 +140,13 @@ export interface StreamState {
131
140
  collab?: boolean;
132
141
  cameraPosition?: CameraPosition | null;
133
142
  cameraSize?: CameraSize | null;
143
+ /**
144
+ * Cloudflare session/track the screen is published on. Set automatically by startScreenShare()
145
+ * so the backend can pull the screen as a live layer and transition the layout server-side
146
+ * (solo → screen_camera, collab → collab_screen). Cleared by stopScreenShare().
147
+ */
148
+ screenSessionId?: string | null;
149
+ screenTrackName?: string | null;
134
150
  /** Server-stamped on each update (read-only on the way back). */
135
151
  updatedAt?: string;
136
152
  }
@@ -213,6 +229,7 @@ export class RestreamStudio extends EventTarget {
213
229
  on(type: "comments", cb: (c: Comment[]) => void): () => void;
214
230
  on(type: "error", cb: (e: Error) => void): () => void;
215
231
  on(type: "state", cb: (s: StreamState) => void): () => void;
232
+ on(type: "screenshare", cb: (e: { active: boolean; sessionId?: string; trackName?: string }) => void): () => void;
216
233
  on(type: "collab" | "collab-invite" | "collab-accepted" | "collab-declined" | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot", cb: (e: CollabEvent) => void): () => void;
217
234
  listConnections(): Promise<Connection[]>;
218
235
  listOAuthPlatforms(): Promise<OAuthPlatformsResponse>;
@@ -253,6 +270,14 @@ export class RestreamStudio extends EventTarget {
253
270
  setStreamState(patch: StreamState): StreamState;
254
271
  /** Last presence/layout state set locally. */
255
272
  readonly streamState: StreamState;
273
+ /** Capture the screen and publish it as its OWN Cloudflare session (a separate layer the
274
+ * backend composites via templates). Pass the returned sessionId to goLive({ screenSessionId }).
275
+ * Restream-only; must be called from a user gesture. */
276
+ startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
277
+ /** Stop screen sharing (close the Cloudflare track + peer connection). */
278
+ stopScreenShare(): Promise<void>;
279
+ /** Cloudflare session id of the live screen-share publish, or null. */
280
+ readonly screenSessionId: string | null;
256
281
  startRealtime(jobId: string): Promise<void>;
257
282
  stopRealtime(): void;
258
283
  // collaborative split-screen
@@ -302,6 +327,8 @@ export interface UseRestream {
302
327
  sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
303
328
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
304
329
  setStreamState(patch: StreamState): StreamState;
330
+ startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
331
+ stopScreenShare(): Promise<void>;
305
332
  requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
306
333
  acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
307
334
  declineCollab(inviteId: string): Promise<CollabInvite>;