restream-sdk 0.1.5 → 0.1.7
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 +2 -2
- package/src/core.js +117 -4
- package/src/react.js +15 -0
- package/types/index.d.ts +90 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restream-sdk",
|
|
3
|
-
"version": "0.1.
|
|
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.7",
|
|
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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// (publisherSessionId) is produced by the client's existing stream and passed
|
|
5
5
|
// into goLive() — this module never captures or publishes media itself.
|
|
6
6
|
|
|
7
|
-
export const PLATFORMS = ["twitch", "kick", "youtube", "facebook", "instagram", "tiktok"];
|
|
7
|
+
export const PLATFORMS = ["twitch", "kick", "youtube", "facebook", "instagram", "tiktok", "restream"];
|
|
8
8
|
|
|
9
9
|
const TERMINAL_STATUS = new Set(["stopped", "failed", "ended", "error", "completed"]);
|
|
10
10
|
|
|
@@ -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);
|
|
@@ -293,8 +295,11 @@ export class RestreamStudio extends EventTarget {
|
|
|
293
295
|
* @param {object} [opts.overlay] { topBanner?, bottomBanner?, htmlBanner?, enabled? }
|
|
294
296
|
* @param {boolean} [opts.recording]
|
|
295
297
|
* @param {string[]} [opts.trackNames] tracks actually published, e.g. ["audio","video"]
|
|
298
|
+
* @param {object} [opts.streamMeta] { title?, description?, tags?:string[] } applied to
|
|
299
|
+
* Restream channels. Any field omitted falls back to the
|
|
300
|
+
* default pre-set for this creator on the platform.
|
|
296
301
|
*/
|
|
297
|
-
async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels } = {}) {
|
|
302
|
+
async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta } = {}) {
|
|
298
303
|
if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
|
|
299
304
|
const body = {
|
|
300
305
|
publisherSessionId,
|
|
@@ -305,6 +310,11 @@ export class RestreamStudio extends EventTarget {
|
|
|
305
310
|
if (trackNames && trackNames.length) body.trackNames = trackNames;
|
|
306
311
|
// Per-platform selection for the "restream" fan-out bridge (channel ids or platform names).
|
|
307
312
|
if (restreamChannels && restreamChannels.length) body.restreamChannels = restreamChannels;
|
|
313
|
+
// Stream title/description/tags. Send only what you have — omitted fields fall
|
|
314
|
+
// back to the creator's pre-set default on the platform.
|
|
315
|
+
if (streamMeta && (streamMeta.title || streamMeta.description || (streamMeta.tags && streamMeta.tags.length))) {
|
|
316
|
+
body.streamMeta = streamMeta;
|
|
317
|
+
}
|
|
308
318
|
if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
|
|
309
319
|
const job = await this._req("POST", "/api/v1/jobs", body);
|
|
310
320
|
this.job = job;
|
|
@@ -430,5 +440,108 @@ export class RestreamStudio extends EventTarget {
|
|
|
430
440
|
this.job = null;
|
|
431
441
|
}
|
|
432
442
|
|
|
433
|
-
|
|
443
|
+
// ---- collaborative split-screen ----
|
|
444
|
+
/** Add the acting user id in publishable-key mode (token mode binds it server-side). */
|
|
445
|
+
_withUser(body = {}) {
|
|
446
|
+
if (this.publishableKey && this.userId) return { ...body, clientUserId: this.userId };
|
|
447
|
+
return body;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Invite another creator (same client) to a split-screen collab. The composite
|
|
452
|
+
* starts only when they accept. Pass your own publisherSessionId + destinations so
|
|
453
|
+
* the server can begin the merged broadcast on accept (or rely on your active job).
|
|
454
|
+
* @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
|
|
455
|
+
*/
|
|
456
|
+
async requestCollab({ collaboratorUserId, collaboratorRestreamOn = false, publisherSessionId, destinations, metadata } = {}) {
|
|
457
|
+
if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
|
|
458
|
+
const invite = await this._req("POST", "/api/v1/collab/request", this._withUser({
|
|
459
|
+
collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata,
|
|
460
|
+
}));
|
|
461
|
+
this._emit("collab-invite", { type: "collab-invite", invite, mine: true });
|
|
462
|
+
return invite;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** List your sent + received collab invites: { sent:[], received:[] }. */
|
|
466
|
+
async listCollabInvites() {
|
|
467
|
+
const qs = this.publishableKey && this.userId ? `?clientUserId=${encodeURIComponent(this.userId)}` : "";
|
|
468
|
+
return this._req("GET", `/api/v1/collab/invites${qs}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Accept an invite and JOIN the composite. Your publisherSessionId (your existing
|
|
473
|
+
* stream session) is required — it becomes the second video in the frame. On success
|
|
474
|
+
* this attaches to the composite job and starts realtime, emitting `collab-composite`.
|
|
475
|
+
*/
|
|
476
|
+
async acceptCollab(inviteId, { publisherSessionId, overlay, recording, metadata } = {}) {
|
|
477
|
+
if (!inviteId) throw new Error("inviteId is required");
|
|
478
|
+
if (!publisherSessionId) throw new Error("publisherSessionId (your stream session) is required to join the composite");
|
|
479
|
+
const r = await this._req("POST", `/api/v1/collab/${inviteId}/accept`, this._withUser({ publisherSessionId, overlay, recording, metadata }));
|
|
480
|
+
if (r?.job?.id) {
|
|
481
|
+
this.job = r.job;
|
|
482
|
+
this._emit("job", r.job);
|
|
483
|
+
this._emit("collab-composite", { type: "collab-composite", invite: r.invite, job: r.job });
|
|
484
|
+
this.startRealtime(r.job.id);
|
|
485
|
+
}
|
|
486
|
+
return r;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Decline a pending invite addressed to you. */
|
|
490
|
+
async declineCollab(inviteId) {
|
|
491
|
+
return this._req("POST", `/api/v1/collab/${inviteId}/decline`, this._withUser({}));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Toggle whether YOUR audience (destinations + Growth) is included in the composite. */
|
|
495
|
+
async setCollabRestream(inviteId, on) {
|
|
496
|
+
return this._req("PATCH", `/api/v1/collab/${inviteId}/restream`, this._withUser({ on: !!on }));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Revoke a pending invite you sent (before it is accepted). */
|
|
500
|
+
async revokeCollab(inviteId) {
|
|
501
|
+
const qs = this.publishableKey && this.userId ? `?clientUserId=${encodeURIComponent(this.userId)}` : "";
|
|
502
|
+
return this._req("DELETE", `/api/v1/collab/${inviteId}${qs}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** End a live collab (stops the composite job). Defaults to the current job. */
|
|
506
|
+
async endCollab(jobId) {
|
|
507
|
+
const id = jobId || this.job?.id;
|
|
508
|
+
if (!id) throw new Error("no active collab job");
|
|
509
|
+
const r = await this._req("POST", `/api/v1/collab/${id}/end`, this._withUser({}));
|
|
510
|
+
this.stopRealtime();
|
|
511
|
+
if (this.job?.id === id) { this.job = { ...this.job, status: "stopping" }; this._emit("job", this.job); }
|
|
512
|
+
return r;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Open the collab signaling stream: emits `collab-invite`, `collab-accepted`,
|
|
517
|
+
* `collab-declined`, `collab-revoked`, `collab-ended`, `collab-expired`, plus a
|
|
518
|
+
* `collab-snapshot` of current invites on connect. Also re-emits each as `collab`.
|
|
519
|
+
*/
|
|
520
|
+
async startCollabRealtime() {
|
|
521
|
+
this.stopCollabRealtime();
|
|
522
|
+
const q = await this._authQuery();
|
|
523
|
+
if (this.publishableKey && this.userId) q.clientUserId = this.userId;
|
|
524
|
+
const es = new EventSource(`${this.apiBase}/api/v1/collab/invites/stream?${new URLSearchParams(q)}`);
|
|
525
|
+
es.onmessage = (e) => {
|
|
526
|
+
let evt;
|
|
527
|
+
try { evt = JSON.parse(e.data); } catch { return; } // heartbeat / non-JSON
|
|
528
|
+
if (!evt || !evt.type) return;
|
|
529
|
+
this._emit(evt.type, evt);
|
|
530
|
+
this._emit("collab", evt);
|
|
531
|
+
};
|
|
532
|
+
es.onerror = () => {
|
|
533
|
+
// Transient drop. token mode carries an expiring token in the URL → reopen fresh.
|
|
534
|
+
if (this.tokenEndpoint && this._collabEs && !this._collabReopen) {
|
|
535
|
+
this._collabReopen = setTimeout(() => { this._collabReopen = null; if (this._collabEs) this.startCollabRealtime(); }, 3000);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
this._collabEs = es;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
stopCollabRealtime() {
|
|
542
|
+
if (this._collabEs) { this._collabEs.close(); this._collabEs = null; }
|
|
543
|
+
if (this._collabReopen) { clearTimeout(this._collabReopen); this._collabReopen = null; }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
destroy() { this.unwatch(); this.stopCollabRealtime(); }
|
|
434
547
|
}
|
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
|
@@ -75,6 +75,18 @@ export interface GoLiveParams {
|
|
|
75
75
|
* Omit to use whichever channels are currently active in the Restream account.
|
|
76
76
|
*/
|
|
77
77
|
restreamChannels?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Stream title / description / tags applied to the Restream channels. Send only
|
|
80
|
+
* the fields you have — any field you omit falls back to the default pre-set for
|
|
81
|
+
* this creator on the platform. Omit entirely to use the pre-set defaults.
|
|
82
|
+
*/
|
|
83
|
+
streamMeta?: StreamMeta;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StreamMeta {
|
|
87
|
+
title?: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
tags?: string[];
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
export interface OverlayUpdate {
|
|
@@ -84,7 +96,63 @@ export interface OverlayUpdate {
|
|
|
84
96
|
hideBottom?: boolean;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
export type RestreamEvent =
|
|
99
|
+
export type RestreamEvent =
|
|
100
|
+
| "connections" | "job" | "viewers" | "comment" | "comments" | "error"
|
|
101
|
+
| "collab" | "collab-invite" | "collab-accepted" | "collab-declined"
|
|
102
|
+
| "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot";
|
|
103
|
+
|
|
104
|
+
/** A collaborative split-screen invite (A invites B; the composite starts on accept). */
|
|
105
|
+
export interface CollabInvite {
|
|
106
|
+
id: string;
|
|
107
|
+
clientId: string;
|
|
108
|
+
issuerUserId: string;
|
|
109
|
+
collaboratorUserId: string;
|
|
110
|
+
issuerExternalId: string;
|
|
111
|
+
collaboratorExternalId: string;
|
|
112
|
+
status: "pending" | "accepted" | "declined" | "revoked" | "ended" | "expired";
|
|
113
|
+
collaboratorRestreamOn: boolean;
|
|
114
|
+
streamJobId: string | null;
|
|
115
|
+
metadata?: Record<string, unknown>;
|
|
116
|
+
createdAt: string;
|
|
117
|
+
acceptedAt?: string | null;
|
|
118
|
+
endedAt?: string | null;
|
|
119
|
+
expiresAt: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface CollabInviteList {
|
|
123
|
+
sent: CollabInvite[];
|
|
124
|
+
received: CollabInvite[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A real-time collab signaling event (from startCollabRealtime()). */
|
|
128
|
+
export interface CollabEvent {
|
|
129
|
+
type: RestreamEvent;
|
|
130
|
+
invite?: CollabInvite;
|
|
131
|
+
job?: Job;
|
|
132
|
+
sent?: CollabInvite[];
|
|
133
|
+
received?: CollabInvite[];
|
|
134
|
+
[k: string]: unknown;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface RequestCollabParams {
|
|
138
|
+
/** The other creator's external id (same client). */
|
|
139
|
+
collaboratorUserId: string;
|
|
140
|
+
/** Include the collaborator's own audience (destinations + Growth) in the composite. */
|
|
141
|
+
collaboratorRestreamOn?: boolean;
|
|
142
|
+
/** Your publisher session (so the composite can start on accept). */
|
|
143
|
+
publisherSessionId?: string;
|
|
144
|
+
/** Your destination platforms for the composite. */
|
|
145
|
+
destinations?: Platform[];
|
|
146
|
+
metadata?: Record<string, unknown>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AcceptCollabParams {
|
|
150
|
+
/** Your existing stream session — becomes the second video in the frame (REQUIRED). */
|
|
151
|
+
publisherSessionId: string;
|
|
152
|
+
overlay?: Overlay;
|
|
153
|
+
recording?: boolean;
|
|
154
|
+
metadata?: Record<string, unknown>;
|
|
155
|
+
}
|
|
88
156
|
|
|
89
157
|
export class RestreamStudio extends EventTarget {
|
|
90
158
|
constructor(opts: RestreamOptions);
|
|
@@ -98,6 +166,7 @@ export class RestreamStudio extends EventTarget {
|
|
|
98
166
|
on(type: "comment", cb: (c: Comment) => void): () => void;
|
|
99
167
|
on(type: "comments", cb: (c: Comment[]) => void): () => void;
|
|
100
168
|
on(type: "error", cb: (e: Error) => void): () => void;
|
|
169
|
+
on(type: "collab" | "collab-invite" | "collab-accepted" | "collab-declined" | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot", cb: (e: CollabEvent) => void): () => void;
|
|
101
170
|
listConnections(): Promise<Connection[]>;
|
|
102
171
|
listOAuthPlatforms(): Promise<{ platforms: Platform[]; frontendOrigin: string; redirectOrigin: string }>;
|
|
103
172
|
listRestreamChannels(): Promise<RestreamChannel[]>;
|
|
@@ -123,6 +192,16 @@ export class RestreamStudio extends EventTarget {
|
|
|
123
192
|
updateOverlay(opts: OverlayUpdate): Promise<unknown>;
|
|
124
193
|
startRealtime(jobId: string): Promise<void>;
|
|
125
194
|
stopRealtime(): void;
|
|
195
|
+
// collaborative split-screen
|
|
196
|
+
requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
|
|
197
|
+
acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
|
|
198
|
+
declineCollab(inviteId: string): Promise<CollabInvite>;
|
|
199
|
+
revokeCollab(inviteId: string): Promise<CollabInvite>;
|
|
200
|
+
setCollabRestream(inviteId: string, on: boolean): Promise<CollabInvite>;
|
|
201
|
+
endCollab(jobId?: string): Promise<unknown>;
|
|
202
|
+
listCollabInvites(): Promise<CollabInviteList>;
|
|
203
|
+
startCollabRealtime(): Promise<void>;
|
|
204
|
+
stopCollabRealtime(): void;
|
|
126
205
|
/** Read-only viewer mode: stream a streamer's live social chat + viewer counts. */
|
|
127
206
|
watch(userId: string, opts?: { pollMs?: number }): Promise<{ id: string } | null>;
|
|
128
207
|
/** Stop watching (clears the poll + realtime streams). */
|
|
@@ -137,6 +216,7 @@ export interface UseRestream {
|
|
|
137
216
|
viewers: ViewerCounts | null;
|
|
138
217
|
comments: Comment[];
|
|
139
218
|
error: Error | null;
|
|
219
|
+
collabInvites: CollabInviteList;
|
|
140
220
|
connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
|
|
141
221
|
disconnect(platform: Platform): Promise<Connection[]>;
|
|
142
222
|
listConnections(): Promise<Connection[]>;
|
|
@@ -153,6 +233,15 @@ export interface UseRestream {
|
|
|
153
233
|
refreshJob(): Promise<Job | null>;
|
|
154
234
|
sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
|
|
155
235
|
updateOverlay(opts: OverlayUpdate): Promise<unknown>;
|
|
236
|
+
requestCollab(opts: RequestCollabParams): Promise<CollabInvite>;
|
|
237
|
+
acceptCollab(inviteId: string, opts: AcceptCollabParams): Promise<{ invite: CollabInvite; job: Job }>;
|
|
238
|
+
declineCollab(inviteId: string): Promise<CollabInvite>;
|
|
239
|
+
revokeCollab(inviteId: string): Promise<CollabInvite>;
|
|
240
|
+
setCollabRestream(inviteId: string, on: boolean): Promise<CollabInvite>;
|
|
241
|
+
endCollab(jobId?: string): Promise<unknown>;
|
|
242
|
+
listCollabInvites(): Promise<CollabInviteList>;
|
|
243
|
+
startCollabRealtime(): Promise<void>;
|
|
244
|
+
stopCollabRealtime(): void;
|
|
156
245
|
}
|
|
157
246
|
|
|
158
247
|
export function useRestream(opts: RestreamOptions): UseRestream;
|