restream-sdk 0.4.2 → 0.5.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.5.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 } = {}) {
397
404
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
398
405
  const body = {
399
406
  publisherSessionId,
@@ -421,6 +428,14 @@ export class RestreamStudio extends EventTarget {
421
428
  if (streamingProvider && typeof streamingProvider === "object" && Object.keys(streamingProvider).length) {
422
429
  body.streamingProvider = streamingProvider;
423
430
  }
431
+ // Screen-share as a separate LAYER: pass the screen-publish session + its track name so the
432
+ // backend pulls the RIGHT track (the SDK publishes it as "screen", not "video") and composites
433
+ // screen + camera per the template. Defaults to the SDK's active screen share.
434
+ const screenId = screenSessionId || this.screenSessionId;
435
+ if (screenId) {
436
+ body.screenSessionId = screenId;
437
+ body.screenTrackName = this._screen?.trackName || "screen";
438
+ }
424
439
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
425
440
  const job = await this._req("POST", "/api/v1/jobs", body);
426
441
  this.job = job;
@@ -502,6 +517,92 @@ export class RestreamStudio extends EventTarget {
502
517
  /** Last presence/layout state set via setStreamState() (local view). */
503
518
  get streamState() { return this._streamState; }
504
519
 
520
+ /** The Cloudflare session id of the live screen-share publish (or null). Pass this to
521
+ * goLive({ screenSessionId }) so the backend pulls the screen as a separate layer. */
522
+ get screenSessionId() { return this._screen?.sessionId || null; }
523
+
524
+ /**
525
+ * Start screen sharing: capture the screen and publish it as its OWN Cloudflare Realtime
526
+ * session (a separate LAYER from the camera). Returns { sessionId, trackName } — pass the
527
+ * sessionId to goLive({ screenSessionId }) (or updateScreenLayer() while live) so the backend
528
+ * composites screen + camera server-side via the template. Restream-only; not shown in the
529
+ * client's own player. Must be called from a user gesture (getDisplayMedia requires it).
530
+ *
531
+ * @param {object} [opts] { trackName='screen', audio=false }
532
+ * @returns {Promise<{sessionId:string, trackName:string}>}
533
+ */
534
+ async startScreenShare({ trackName = "screen", audio = false } = {}) {
535
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
536
+ throw new Error("Screen sharing is not supported in this environment");
537
+ }
538
+ if (this._screen) return { sessionId: this._screen.sessionId, trackName: this._screen.trackName };
539
+
540
+ // 1) Capture the screen.
541
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio });
542
+ const track = stream.getVideoTracks()[0];
543
+ if (!track) { stream.getTracks().forEach((t) => t.stop()); throw new Error("no screen video track"); }
544
+
545
+ try {
546
+ // 2) ICE servers + peer connection.
547
+ const { iceServers } = await this._req("GET", "/api/streams/turn-credentials");
548
+ const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle" });
549
+ const transceiver = pc.addTransceiver(track, { direction: "sendonly" });
550
+
551
+ // 3) New Cloudflare Realtime session (brokered by our backend — CF creds stay server-side).
552
+ const session = await this._req("POST", "/api/streams/sessions", {});
553
+ const sessionId = session.sessionId;
554
+ if (!sessionId) throw new Error("no sessionId from /streams/sessions");
555
+
556
+ // 4) Offer, wait for ICE gathering (CF wants the full offer), then push the LOCAL track.
557
+ await pc.setLocalDescription(await pc.createOffer());
558
+ await this._waitIceComplete(pc);
559
+ const res = await this._req("POST", `/api/streams/sessions/${sessionId}/tracks/new`, {
560
+ sessionDescription: { type: "offer", sdp: pc.localDescription.sdp },
561
+ tracks: [{ location: "local", mid: transceiver.mid, trackName }],
562
+ });
563
+ if (res?.sessionDescription) await pc.setRemoteDescription(new RTCSessionDescription(res.sessionDescription));
564
+
565
+ // Stop-sharing from the browser's own UI must tear us down too.
566
+ track.addEventListener("ended", () => { this.stopScreenShare().catch(() => {}); });
567
+
568
+ this._screen = { pc, track, stream, sessionId, trackName };
569
+ // Signal the backend WHICH session/track the screen is on so it can pull it as a live layer
570
+ // and transition the layout server-side (solo → screen_camera, collab → collab_screen).
571
+ this.setStreamState({ screenShare: true, screenSessionId: sessionId, screenTrackName: trackName });
572
+ this._emit("screenshare", { active: true, sessionId, trackName });
573
+ return { sessionId, trackName };
574
+ } catch (err) {
575
+ try { stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
576
+ throw err;
577
+ }
578
+ }
579
+
580
+ /** Stop screen sharing: close the Cloudflare track + tear down the peer connection. */
581
+ async stopScreenShare() {
582
+ const s = this._screen;
583
+ if (!s) return;
584
+ this._screen = null;
585
+ 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 */ }
586
+ try { s.track.stop(); } catch { /* ignore */ }
587
+ try { s.stream.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
588
+ try { s.pc.close(); } catch { /* ignore */ }
589
+ // Clear the screen session so the backend reverts the layout (screen_camera → solo,
590
+ // collab_screen → sidebyside).
591
+ this.setStreamState({ screenShare: false, screenSessionId: null, screenTrackName: null });
592
+ this._emit("screenshare", { active: false });
593
+ }
594
+
595
+ // Resolve once ICE gathering completes (or after a short timeout) so the offer carries candidates.
596
+ _waitIceComplete(pc, timeoutMs = 3000) {
597
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
598
+ return new Promise((resolve) => {
599
+ const done = () => { pc.removeEventListener("icegatheringstatechange", check); clearTimeout(t); resolve(); };
600
+ const check = () => { if (pc.iceGatheringState === "complete") done(); };
601
+ const t = setTimeout(done, timeoutMs);
602
+ pc.addEventListener("icegatheringstatechange", check);
603
+ });
604
+ }
605
+
505
606
  // ---- realtime (chat SSE + job-status/viewer polling) ----
506
607
  async startRealtime(jobId) {
507
608
  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,9 @@ 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;
107
110
  }
108
111
 
109
112
  export interface StreamMeta {
@@ -131,6 +134,13 @@ export interface StreamState {
131
134
  collab?: boolean;
132
135
  cameraPosition?: CameraPosition | null;
133
136
  cameraSize?: CameraSize | null;
137
+ /**
138
+ * Cloudflare session/track the screen is published on. Set automatically by startScreenShare()
139
+ * so the backend can pull the screen as a live layer and transition the layout server-side
140
+ * (solo → screen_camera, collab → collab_screen). Cleared by stopScreenShare().
141
+ */
142
+ screenSessionId?: string | null;
143
+ screenTrackName?: string | null;
134
144
  /** Server-stamped on each update (read-only on the way back). */
135
145
  updatedAt?: string;
136
146
  }
@@ -213,6 +223,7 @@ export class RestreamStudio extends EventTarget {
213
223
  on(type: "comments", cb: (c: Comment[]) => void): () => void;
214
224
  on(type: "error", cb: (e: Error) => void): () => void;
215
225
  on(type: "state", cb: (s: StreamState) => void): () => void;
226
+ on(type: "screenshare", cb: (e: { active: boolean; sessionId?: string; trackName?: string }) => void): () => void;
216
227
  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
228
  listConnections(): Promise<Connection[]>;
218
229
  listOAuthPlatforms(): Promise<OAuthPlatformsResponse>;
@@ -253,6 +264,14 @@ export class RestreamStudio extends EventTarget {
253
264
  setStreamState(patch: StreamState): StreamState;
254
265
  /** Last presence/layout state set locally. */
255
266
  readonly streamState: StreamState;
267
+ /** Capture the screen and publish it as its OWN Cloudflare session (a separate layer the
268
+ * backend composites via templates). Pass the returned sessionId to goLive({ screenSessionId }).
269
+ * Restream-only; must be called from a user gesture. */
270
+ startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
271
+ /** Stop screen sharing (close the Cloudflare track + peer connection). */
272
+ stopScreenShare(): Promise<void>;
273
+ /** Cloudflare session id of the live screen-share publish, or null. */
274
+ readonly screenSessionId: string | null;
256
275
  startRealtime(jobId: string): Promise<void>;
257
276
  stopRealtime(): void;
258
277
  // collaborative split-screen
@@ -302,6 +321,8 @@ export interface UseRestream {
302
321
  sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
303
322
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
304
323
  setStreamState(patch: StreamState): StreamState;
324
+ startScreenShare(opts?: { trackName?: string; audio?: boolean }): Promise<{ sessionId: string; trackName: string }>;
325
+ stopScreenShare(): Promise<void>;
305
326
  requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
306
327
  acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
307
328
  declineCollab(inviteId: string): Promise<CollabInvite>;