restream-sdk 0.1.6 → 0.1.8

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,7 +1,7 @@
1
1
  {
2
2
  "name": "restream-sdk",
3
- "version": "0.1.6",
4
- "description": "Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers) with zero client backend.",
3
+ "version": "0.1.8",
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",
7
7
  "module": "src/index.js",
package/src/core.js CHANGED
@@ -41,11 +41,13 @@ export class RestreamStudio extends EventTarget {
41
41
  this._armed = null;
42
42
  this._watchPoll = null;
43
43
  this._watchUserId = null;
44
+ this._collabEs = null;
45
+ this._collabReopen = null;
44
46
  }
45
47
 
46
48
  _emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
47
49
 
48
- /** Subscribe to an event ("connections" | "job" | "viewers" | "comment" | "comments" | "error"). Returns an unsubscribe fn. */
50
+ /** Subscribe to an event ("connections" | "job" | "viewers" | "comment" | "comments" | "error" | "collab" | "collab-invite" | "collab-accepted" | "collab-declined" | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot"). Returns an unsubscribe fn. */
49
51
  on(type, cb) {
50
52
  const handler = (e) => cb(e.detail);
51
53
  this.addEventListener(type, handler);
@@ -297,7 +299,7 @@ export class RestreamStudio extends EventTarget {
297
299
  * Restream channels. Any field omitted falls back to the
298
300
  * default pre-set for this creator on the platform.
299
301
  */
300
- async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta } = {}) {
302
+ async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta } = {}) {
301
303
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
302
304
  const body = {
303
305
  publisherSessionId,
@@ -313,6 +315,12 @@ export class RestreamStudio extends EventTarget {
313
315
  if (streamMeta && (streamMeta.title || streamMeta.description || (streamMeta.tags && streamMeta.tags.length))) {
314
316
  body.streamMeta = streamMeta;
315
317
  }
318
+ // Per-destination overrides, keyed by platform name (e.g. { youtube: {title},
319
+ // twitch: {title, tags} }). Each field falls back to streamMeta, then the
320
+ // creator's pre-set per-destination / global default on the platform.
321
+ if (destinationMeta && typeof destinationMeta === "object" && Object.keys(destinationMeta).length) {
322
+ body.destinationMeta = destinationMeta;
323
+ }
316
324
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
317
325
  const job = await this._req("POST", "/api/v1/jobs", body);
318
326
  this.job = job;
@@ -438,5 +446,108 @@ export class RestreamStudio extends EventTarget {
438
446
  this.job = null;
439
447
  }
440
448
 
441
- destroy() { this.unwatch(); }
449
+ // ---- collaborative split-screen ----
450
+ /** Add the acting user id in publishable-key mode (token mode binds it server-side). */
451
+ _withUser(body = {}) {
452
+ if (this.publishableKey && this.userId) return { ...body, clientUserId: this.userId };
453
+ return body;
454
+ }
455
+
456
+ /**
457
+ * Invite another creator (same client) to a split-screen collab. The composite
458
+ * starts only when they accept. Pass your own publisherSessionId + destinations so
459
+ * the server can begin the merged broadcast on accept (or rely on your active job).
460
+ * @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
461
+ */
462
+ async requestCollab({ collaboratorUserId, collaboratorRestreamOn = false, publisherSessionId, destinations, metadata } = {}) {
463
+ if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
464
+ const invite = await this._req("POST", "/api/v1/collab/request", this._withUser({
465
+ collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata,
466
+ }));
467
+ this._emit("collab-invite", { type: "collab-invite", invite, mine: true });
468
+ return invite;
469
+ }
470
+
471
+ /** List your sent + received collab invites: { sent:[], received:[] }. */
472
+ async listCollabInvites() {
473
+ const qs = this.publishableKey && this.userId ? `?clientUserId=${encodeURIComponent(this.userId)}` : "";
474
+ return this._req("GET", `/api/v1/collab/invites${qs}`);
475
+ }
476
+
477
+ /**
478
+ * Accept an invite and JOIN the composite. Your publisherSessionId (your existing
479
+ * stream session) is required — it becomes the second video in the frame. On success
480
+ * this attaches to the composite job and starts realtime, emitting `collab-composite`.
481
+ */
482
+ async acceptCollab(inviteId, { publisherSessionId, overlay, recording, metadata } = {}) {
483
+ if (!inviteId) throw new Error("inviteId is required");
484
+ if (!publisherSessionId) throw new Error("publisherSessionId (your stream session) is required to join the composite");
485
+ const r = await this._req("POST", `/api/v1/collab/${inviteId}/accept`, this._withUser({ publisherSessionId, overlay, recording, metadata }));
486
+ if (r?.job?.id) {
487
+ this.job = r.job;
488
+ this._emit("job", r.job);
489
+ this._emit("collab-composite", { type: "collab-composite", invite: r.invite, job: r.job });
490
+ this.startRealtime(r.job.id);
491
+ }
492
+ return r;
493
+ }
494
+
495
+ /** Decline a pending invite addressed to you. */
496
+ async declineCollab(inviteId) {
497
+ return this._req("POST", `/api/v1/collab/${inviteId}/decline`, this._withUser({}));
498
+ }
499
+
500
+ /** Toggle whether YOUR audience (destinations + Growth) is included in the composite. */
501
+ async setCollabRestream(inviteId, on) {
502
+ return this._req("PATCH", `/api/v1/collab/${inviteId}/restream`, this._withUser({ on: !!on }));
503
+ }
504
+
505
+ /** Revoke a pending invite you sent (before it is accepted). */
506
+ async revokeCollab(inviteId) {
507
+ const qs = this.publishableKey && this.userId ? `?clientUserId=${encodeURIComponent(this.userId)}` : "";
508
+ return this._req("DELETE", `/api/v1/collab/${inviteId}${qs}`);
509
+ }
510
+
511
+ /** End a live collab (stops the composite job). Defaults to the current job. */
512
+ async endCollab(jobId) {
513
+ const id = jobId || this.job?.id;
514
+ if (!id) throw new Error("no active collab job");
515
+ const r = await this._req("POST", `/api/v1/collab/${id}/end`, this._withUser({}));
516
+ this.stopRealtime();
517
+ if (this.job?.id === id) { this.job = { ...this.job, status: "stopping" }; this._emit("job", this.job); }
518
+ return r;
519
+ }
520
+
521
+ /**
522
+ * Open the collab signaling stream: emits `collab-invite`, `collab-accepted`,
523
+ * `collab-declined`, `collab-revoked`, `collab-ended`, `collab-expired`, plus a
524
+ * `collab-snapshot` of current invites on connect. Also re-emits each as `collab`.
525
+ */
526
+ async startCollabRealtime() {
527
+ this.stopCollabRealtime();
528
+ const q = await this._authQuery();
529
+ if (this.publishableKey && this.userId) q.clientUserId = this.userId;
530
+ const es = new EventSource(`${this.apiBase}/api/v1/collab/invites/stream?${new URLSearchParams(q)}`);
531
+ es.onmessage = (e) => {
532
+ let evt;
533
+ try { evt = JSON.parse(e.data); } catch { return; } // heartbeat / non-JSON
534
+ if (!evt || !evt.type) return;
535
+ this._emit(evt.type, evt);
536
+ this._emit("collab", evt);
537
+ };
538
+ es.onerror = () => {
539
+ // Transient drop. token mode carries an expiring token in the URL → reopen fresh.
540
+ if (this.tokenEndpoint && this._collabEs && !this._collabReopen) {
541
+ this._collabReopen = setTimeout(() => { this._collabReopen = null; if (this._collabEs) this.startCollabRealtime(); }, 3000);
542
+ }
543
+ };
544
+ this._collabEs = es;
545
+ }
546
+
547
+ stopCollabRealtime() {
548
+ if (this._collabEs) { this._collabEs.close(); this._collabEs = null; }
549
+ if (this._collabReopen) { clearTimeout(this._collabReopen); this._collabReopen = null; }
550
+ }
551
+
552
+ destroy() { this.unwatch(); this.stopCollabRealtime(); }
442
553
  }
package/src/react.js CHANGED
@@ -17,14 +17,18 @@ export function useRestream(opts) {
17
17
  const [viewers, setViewers] = useState(null);
18
18
  const [comments, setComments] = useState([]);
19
19
  const [error, setError] = useState(null);
20
+ const [collabInvites, setCollabInvites] = useState({ sent: [], received: [] });
20
21
 
21
22
  useEffect(() => {
23
+ const refreshInvites = () => studio.listCollabInvites().then(setCollabInvites).catch(() => {});
22
24
  const offs = [
23
25
  studio.on("connections", setConnections),
24
26
  studio.on("job", setJob),
25
27
  studio.on("viewers", setViewers),
26
28
  studio.on("comments", setComments),
27
29
  studio.on("error", setError),
30
+ // Any collab event refreshes the invite lists (snapshot arrives on connect).
31
+ studio.on("collab", refreshInvites),
28
32
  ];
29
33
  studio.listConnections().catch(() => {});
30
34
  return () => { offs.forEach((off) => off()); studio.destroy(); };
@@ -37,6 +41,7 @@ export function useRestream(opts) {
37
41
  viewers,
38
42
  comments,
39
43
  error,
44
+ collabInvites,
40
45
  connect: useCallback((p) => studio.connect(p), [studio]),
41
46
  disconnect: useCallback((p) => studio.disconnect(p), [studio]),
42
47
  listConnections: useCallback(() => studio.listConnections(), [studio]),
@@ -50,6 +55,16 @@ export function useRestream(opts) {
50
55
  refreshJob: useCallback(() => studio.refreshJob(), [studio]),
51
56
  sendChat: useCallback((m, p) => studio.sendChat(m, p), [studio]),
52
57
  updateOverlay: useCallback((o) => studio.updateOverlay(o), [studio]),
58
+ // collaborative split-screen
59
+ requestCollab: useCallback((o) => studio.requestCollab(o), [studio]),
60
+ acceptCollab: useCallback((id, o) => studio.acceptCollab(id, o), [studio]),
61
+ declineCollab: useCallback((id) => studio.declineCollab(id), [studio]),
62
+ revokeCollab: useCallback((id) => studio.revokeCollab(id), [studio]),
63
+ setCollabRestream: useCallback((id, on) => studio.setCollabRestream(id, on), [studio]),
64
+ endCollab: useCallback((id) => studio.endCollab(id), [studio]),
65
+ listCollabInvites: useCallback(() => studio.listCollabInvites(), [studio]),
66
+ startCollabRealtime: useCallback(() => studio.startCollabRealtime(), [studio]),
67
+ stopCollabRealtime: useCallback(() => studio.stopCollabRealtime(), [studio]),
53
68
  };
54
69
  }
55
70
 
package/types/index.d.ts CHANGED
@@ -81,6 +81,13 @@ export interface GoLiveParams {
81
81
  * this creator on the platform. Omit entirely to use the pre-set defaults.
82
82
  */
83
83
  streamMeta?: StreamMeta;
84
+ /**
85
+ * Per-destination overrides, keyed by platform name (e.g.
86
+ * `{ youtube: { title }, twitch: { title, tags } }`). Each field falls back, in
87
+ * order, to: this map → `streamMeta` → the creator's pre-set per-destination
88
+ * default → the creator's pre-set global default → the platform's existing value.
89
+ */
90
+ destinationMeta?: DestinationMeta;
84
91
  }
85
92
 
86
93
  export interface StreamMeta {
@@ -89,6 +96,9 @@ export interface StreamMeta {
89
96
  tags?: string[];
90
97
  }
91
98
 
99
+ /** Per-platform stream metadata overrides, keyed by platform name. */
100
+ export type DestinationMeta = Partial<Record<Platform, StreamMeta>> & Record<string, StreamMeta>;
101
+
92
102
  export interface OverlayUpdate {
93
103
  topBanner?: string;
94
104
  bottomBanner?: string;
@@ -96,7 +106,63 @@ export interface OverlayUpdate {
96
106
  hideBottom?: boolean;
97
107
  }
98
108
 
99
- export type RestreamEvent = "connections" | "job" | "viewers" | "comment" | "comments" | "error";
109
+ export type RestreamEvent =
110
+ | "connections" | "job" | "viewers" | "comment" | "comments" | "error"
111
+ | "collab" | "collab-invite" | "collab-accepted" | "collab-declined"
112
+ | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot";
113
+
114
+ /** A collaborative split-screen invite (A invites B; the composite starts on accept). */
115
+ export interface CollabInvite {
116
+ id: string;
117
+ clientId: string;
118
+ issuerUserId: string;
119
+ collaboratorUserId: string;
120
+ issuerExternalId: string;
121
+ collaboratorExternalId: string;
122
+ status: "pending" | "accepted" | "declined" | "revoked" | "ended" | "expired";
123
+ collaboratorRestreamOn: boolean;
124
+ streamJobId: string | null;
125
+ metadata?: Record<string, unknown>;
126
+ createdAt: string;
127
+ acceptedAt?: string | null;
128
+ endedAt?: string | null;
129
+ expiresAt: string;
130
+ }
131
+
132
+ export interface CollabInviteList {
133
+ sent: CollabInvite[];
134
+ received: CollabInvite[];
135
+ }
136
+
137
+ /** A real-time collab signaling event (from startCollabRealtime()). */
138
+ export interface CollabEvent {
139
+ type: RestreamEvent;
140
+ invite?: CollabInvite;
141
+ job?: Job;
142
+ sent?: CollabInvite[];
143
+ received?: CollabInvite[];
144
+ [k: string]: unknown;
145
+ }
146
+
147
+ export interface RequestCollabParams {
148
+ /** The other creator's external id (same client). */
149
+ collaboratorUserId: string;
150
+ /** Include the collaborator's own audience (destinations + Growth) in the composite. */
151
+ collaboratorRestreamOn?: boolean;
152
+ /** Your publisher session (so the composite can start on accept). */
153
+ publisherSessionId?: string;
154
+ /** Your destination platforms for the composite. */
155
+ destinations?: Platform[];
156
+ metadata?: Record<string, unknown>;
157
+ }
158
+
159
+ export interface AcceptCollabParams {
160
+ /** Your existing stream session — becomes the second video in the frame (REQUIRED). */
161
+ publisherSessionId: string;
162
+ overlay?: Overlay;
163
+ recording?: boolean;
164
+ metadata?: Record<string, unknown>;
165
+ }
100
166
 
101
167
  export class RestreamStudio extends EventTarget {
102
168
  constructor(opts: RestreamOptions);
@@ -110,6 +176,7 @@ export class RestreamStudio extends EventTarget {
110
176
  on(type: "comment", cb: (c: Comment) => void): () => void;
111
177
  on(type: "comments", cb: (c: Comment[]) => void): () => void;
112
178
  on(type: "error", cb: (e: Error) => void): () => void;
179
+ on(type: "collab" | "collab-invite" | "collab-accepted" | "collab-declined" | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot", cb: (e: CollabEvent) => void): () => void;
113
180
  listConnections(): Promise<Connection[]>;
114
181
  listOAuthPlatforms(): Promise<{ platforms: Platform[]; frontendOrigin: string; redirectOrigin: string }>;
115
182
  listRestreamChannels(): Promise<RestreamChannel[]>;
@@ -135,6 +202,16 @@ export class RestreamStudio extends EventTarget {
135
202
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
136
203
  startRealtime(jobId: string): Promise<void>;
137
204
  stopRealtime(): void;
205
+ // collaborative split-screen
206
+ requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
207
+ acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
208
+ declineCollab(inviteId: string): Promise<CollabInvite>;
209
+ revokeCollab(inviteId: string): Promise<CollabInvite>;
210
+ setCollabRestream(inviteId: string, on: boolean): Promise<CollabInvite>;
211
+ endCollab(jobId?: string): Promise<unknown>;
212
+ listCollabInvites(): Promise<CollabInviteList>;
213
+ startCollabRealtime(): Promise<void>;
214
+ stopCollabRealtime(): void;
138
215
  /** Read-only viewer mode: stream a streamer's live social chat + viewer counts. */
139
216
  watch(userId: string, opts?: { pollMs?: number }): Promise<{ id: string } | null>;
140
217
  /** Stop watching (clears the poll + realtime streams). */
@@ -149,6 +226,7 @@ export interface UseRestream {
149
226
  viewers: ViewerCounts | null;
150
227
  comments: Comment[];
151
228
  error: Error | null;
229
+ collabInvites: CollabInviteList;
152
230
  connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
153
231
  disconnect(platform: Platform): Promise<Connection[]>;
154
232
  listConnections(): Promise<Connection[]>;
@@ -165,6 +243,15 @@ export interface UseRestream {
165
243
  refreshJob(): Promise<Job | null>;
166
244
  sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
167
245
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
246
+ requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
247
+ acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
248
+ declineCollab(inviteId: string): Promise<CollabInvite>;
249
+ revokeCollab(inviteId: string): Promise<CollabInvite>;
250
+ setCollabRestream(inviteId: string, on: boolean): Promise<CollabInvite>;
251
+ endCollab(jobId?: string): Promise<unknown>;
252
+ listCollabInvites(): Promise<CollabInviteList>;
253
+ startCollabRealtime(): Promise<void>;
254
+ stopCollabRealtime(): void;
168
255
  }
169
256
 
170
257
  export function useRestream(opts: RestreamOptions): UseRestream;