restream-sdk 0.4.1 → 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 +1 -1
- package/src/core.js +107 -3
- package/src/react.js +2 -0
- package/types/index.d.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restream-sdk",
|
|
3
|
-
"version": "0.
|
|
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,17 +428,28 @@ 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;
|
|
427
442
|
this._emit("job", job);
|
|
428
443
|
if (job?.id) this.startRealtime(job.id);
|
|
429
444
|
// Push the initial presence/layout state (explicit `layout`, or whatever was set via
|
|
430
|
-
// setStreamState() before we had a job id).
|
|
445
|
+
// setStreamState() before we had a job id). setStreamState is synchronous (returns the
|
|
446
|
+
// merged state, not a Promise) and its network send is debounced + self-handles errors,
|
|
447
|
+
// so DON'T await/.catch it — doing so threw and made goLive reject after the job had
|
|
448
|
+
// already started. Wrapped defensively so a flush error can never reject go-live.
|
|
431
449
|
const initialState = { ...(this._pendingState || {}), ...(layout || {}) };
|
|
432
450
|
if (job?.id && Object.keys(initialState).length) {
|
|
433
451
|
this._pendingState = null;
|
|
434
|
-
this.setStreamState(initialState)
|
|
452
|
+
try { this.setStreamState(initialState); } catch (e) { this._emit("error", e); }
|
|
435
453
|
}
|
|
436
454
|
return job;
|
|
437
455
|
}
|
|
@@ -499,6 +517,92 @@ export class RestreamStudio extends EventTarget {
|
|
|
499
517
|
/** Last presence/layout state set via setStreamState() (local view). */
|
|
500
518
|
get streamState() { return this._streamState; }
|
|
501
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
|
+
|
|
502
606
|
// ---- realtime (chat SSE + job-status/viewer polling) ----
|
|
503
607
|
async startRealtime(jobId) {
|
|
504
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>;
|