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 +1 -1
- package/src/core.js +107 -1
- package/src/react.js +2 -0
- package/types/index.d.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restream-sdk",
|
|
3
|
-
"version": "0.
|
|
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>;
|