restream-sdk 0.1.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/LICENSE +21 -0
- package/README.md +284 -0
- package/package.json +42 -0
- package/src/core.js +271 -0
- package/src/index.js +2 -0
- package/src/react.js +71 -0
- package/types/core.d.ts +14 -0
- package/types/index.d.ts +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ravant Technologies FZCO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# restream-sdk
|
|
2
|
+
|
|
3
|
+
Add **multi-platform restreaming** to your app in minutes. Your streamers connect
|
|
4
|
+
their Twitch / Kick / YouTube accounts and you fan an existing live stream out to
|
|
5
|
+
all of them — with **live chat and viewer counts** aggregated back into your UI.
|
|
6
|
+
|
|
7
|
+
- ⚡️ **Zero backend.** Set a publishable key and go. (An optional one-route token
|
|
8
|
+
mode is available when you want hard per-user security.)
|
|
9
|
+
- 🎛 **Headless.** React hooks only — you build the UI with your own design system.
|
|
10
|
+
- 🔌 **You already have the stream.** This is a *control plane*: you hand it the
|
|
11
|
+
`publisherSessionId` from your existing stream and it manages the restream. It
|
|
12
|
+
does **not** capture or publish media.
|
|
13
|
+
- 🔑 **No OAuth setup.** Connecting social accounts is hosted for you — your
|
|
14
|
+
streamers click "Connect Twitch", you register nothing on any platform.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Table of contents
|
|
19
|
+
- [Install](#install)
|
|
20
|
+
- [Prerequisites](#prerequisites)
|
|
21
|
+
- [Quickstart (zero backend)](#quickstart-zero-backend)
|
|
22
|
+
- [How it fits together](#how-it-fits-together)
|
|
23
|
+
- [Connecting accounts](#connecting-accounts)
|
|
24
|
+
- [Going live](#going-live)
|
|
25
|
+
- [Live chat & viewers](#live-chat--viewers)
|
|
26
|
+
- [Banners / overlays](#banners--overlays)
|
|
27
|
+
- [Hardened mode (optional backend)](#hardened-mode-optional-one-route-backend)
|
|
28
|
+
- [API reference](#api-reference)
|
|
29
|
+
- [Scopes](#scopes)
|
|
30
|
+
- [Framework notes (Next.js / SSR)](#framework-notes)
|
|
31
|
+
- [Troubleshooting](#troubleshooting)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install restream-sdk
|
|
39
|
+
# peer dependency: react >= 17
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It's a normal dependency — bundled into your app at build time. Your end users
|
|
43
|
+
install nothing.
|
|
44
|
+
|
|
45
|
+
## Prerequisites
|
|
46
|
+
|
|
47
|
+
From whoever operates the Restream Service, you need:
|
|
48
|
+
|
|
49
|
+
| Thing | What it is |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **API base URL** | e.g. `https://api.restream.example` — where the service runs. |
|
|
52
|
+
| **Publishable key** | `pk_live_…` created in the operator's **Admin → Clients → Keys**, scoped + locked to your app's origin(s). Safe to ship in your frontend. |
|
|
53
|
+
| **A `publisherSessionId`** | Comes from **your existing stream session** (see [below](#where-does-publishersessionid-come-from)). |
|
|
54
|
+
|
|
55
|
+
You do **not** register any OAuth apps or configure any platform webhooks — that's hosted.
|
|
56
|
+
|
|
57
|
+
## Quickstart (zero backend)
|
|
58
|
+
|
|
59
|
+
```jsx
|
|
60
|
+
"use client";
|
|
61
|
+
import { useRestream } from "restream-sdk";
|
|
62
|
+
|
|
63
|
+
export default function Studio({ streamerId, publisherSessionId }) {
|
|
64
|
+
const {
|
|
65
|
+
connections, connect, disconnect,
|
|
66
|
+
goLive, stopLive, job,
|
|
67
|
+
viewers, comments, sendChat,
|
|
68
|
+
} = useRestream({
|
|
69
|
+
apiBase: "https://api.restream.example",
|
|
70
|
+
publishableKey: "pk_live_xxx",
|
|
71
|
+
userId: streamerId, // your own user id for this streamer
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const connected = (p) => connections.find((c) => c.platform === p);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
{/* 1 — connect socials (hosted OAuth popup) */}
|
|
79
|
+
{["twitch", "kick"].map((p) =>
|
|
80
|
+
connected(p)
|
|
81
|
+
? <button key={p} onClick={() => disconnect(p)}>Disconnect {p}</button>
|
|
82
|
+
: <button key={p} onClick={() => connect(p)}>Connect {p}</button>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* 2 — start / stop restreaming your existing session */}
|
|
86
|
+
{!job
|
|
87
|
+
? <button onClick={() => goLive({ publisherSessionId, destinations: ["twitch", "kick"] })}>
|
|
88
|
+
Go live
|
|
89
|
+
</button>
|
|
90
|
+
: <button onClick={stopLive}>Stop ({job.status})</button>}
|
|
91
|
+
|
|
92
|
+
{/* 3 — live stats + chat (both directions) */}
|
|
93
|
+
<p>Viewers: {viewers?.total ?? 0}</p>
|
|
94
|
+
<ul>{comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
|
|
95
|
+
<button onClick={() => sendChat("hello everyone 👋")}>Say hi to all chats</button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## How it fits together
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Your existing stream ──(produces a session)──► publisherSessionId
|
|
105
|
+
│ you pass it to goLive()
|
|
106
|
+
Your app + restream-sdk ────────────────────────►│ Restream Service API
|
|
107
|
+
(publishable key in the browser) ├─ hosted OAuth (you register nothing)
|
|
108
|
+
├─ start/stop restream
|
|
109
|
+
├─ viewers · comments (SSE) · send chat
|
|
110
|
+
└─ fans your stream out to Twitch/Kick/…
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The browser talks to the service directly with the **publishable key** (origin‑locked
|
|
114
|
+
+ scoped). OAuth secrets, platform tokens, and the service's master API key never
|
|
115
|
+
touch the browser.
|
|
116
|
+
|
|
117
|
+
### Where does `publisherSessionId` come from?
|
|
118
|
+
|
|
119
|
+
From **your existing streaming setup** — whatever already produces your live session
|
|
120
|
+
hands you that id, and you pass it straight into `goLive()`. The module is a control
|
|
121
|
+
plane; it doesn't capture camera or publish media. (If you don't have a session
|
|
122
|
+
pipeline yet, ask the operator for the recommended way to produce one.)
|
|
123
|
+
|
|
124
|
+
## Connecting accounts
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
await connect("twitch"); // opens a hosted OAuth popup, resolves when connected
|
|
128
|
+
await disconnect("kick");
|
|
129
|
+
connections; // [{ platform, platformUsername, ... }] (tokens are never exposed)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`connect(platform)` opens a popup to the service's hosted OAuth (using the
|
|
133
|
+
operator's shared platform apps) and resolves when the account is linked. **Allow
|
|
134
|
+
popups** for your origin. Supported: `twitch`, `kick`, `youtube`, `facebook`
|
|
135
|
+
(availability depends on what the operator has enabled).
|
|
136
|
+
|
|
137
|
+
## Going live
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const job = await goLive({
|
|
141
|
+
publisherSessionId, // required — your existing session
|
|
142
|
+
destinations: ["twitch", "kick"], // which connected platforms to fan out to
|
|
143
|
+
overlay: { htmlBanner: "https://cdn.you.com/banner.html", enabled: true }, // optional
|
|
144
|
+
recording: false, // optional
|
|
145
|
+
});
|
|
146
|
+
// ...
|
|
147
|
+
await stopLive();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **One live stream per streamer.** Calling `goLive()` again for the same `userId`
|
|
151
|
+
supersedes the previous job (the new one takes over).
|
|
152
|
+
- **`arm({ destinations })`** lets you pre-select destinations so you can fire
|
|
153
|
+
`goLive` automatically the moment your session starts.
|
|
154
|
+
|
|
155
|
+
## Live chat & viewers
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
viewers; // { total, platforms: { twitch: n, kick: n } } (polled)
|
|
159
|
+
comments; // merged incoming chat from all destinations (live via SSE)
|
|
160
|
+
await sendChat("gg!"); // fan a message OUT to every connected chat
|
|
161
|
+
await sendChat("ty", ["kick"]); // …or just some platforms
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Banners / overlays
|
|
165
|
+
|
|
166
|
+
Pass an `overlay` to `goLive` to composite a banner onto the outgoing stream
|
|
167
|
+
(rendered + burned in server-side, so it shows on every destination):
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
goLive({
|
|
171
|
+
publisherSessionId,
|
|
172
|
+
destinations: ["twitch"],
|
|
173
|
+
overlay: { htmlBanner: "https://cdn.you.com/scoreboard.html", enabled: true },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `htmlBanner` — a URL to a **transparent HTML5 banner**. Your banner can run its
|
|
178
|
+
own JS/data feed (scoreboards, tickers, live stats) — the service just renders
|
|
179
|
+
and composites it.
|
|
180
|
+
- `topBanner` / `bottomBanner` — image (PNG) URLs for simple top/bottom bars.
|
|
181
|
+
- Update PNG bars on a live job with `updateOverlay({ topBanner, bottomBanner, hideTop, hideBottom })`.
|
|
182
|
+
|
|
183
|
+
## Hardened mode (optional, one‑route backend)
|
|
184
|
+
|
|
185
|
+
The publishable key is origin‑locked and scoped, which is enough for most apps. If
|
|
186
|
+
you don't want to trust the browser with *which* `userId` it streams as, mint a
|
|
187
|
+
short‑lived token on your server (one tiny route) and point the module at it:
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
// your backend — call us with your SECRET api key (never sent to the browser)
|
|
191
|
+
app.get("/api/restream-token", async (req, res) => {
|
|
192
|
+
const r = await fetch("https://api.restream.example/api/v1/auth/session", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "X-API-Key": process.env.RESTREAM_API_KEY, "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({ userId: req.user.id }), // bound server-side
|
|
196
|
+
});
|
|
197
|
+
res.json(await r.json()); // { token, expiresIn }
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```jsx
|
|
202
|
+
useRestream({ apiBase: "https://api.restream.example", tokenEndpoint: "/api/restream-token" });
|
|
203
|
+
// no userId/publishableKey in the browser — the user is bound by the token.
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## API reference
|
|
207
|
+
|
|
208
|
+
### `useRestream(options) → state + actions`
|
|
209
|
+
|
|
210
|
+
**Options:** `{ apiBase, publishableKey?, tokenEndpoint?, userId?, timeoutMs? }`
|
|
211
|
+
(provide either `publishableKey`+`userId`, or `tokenEndpoint`.) `job.status` is
|
|
212
|
+
polled live (`queued → running → stopped`); incoming chat is de-duplicated.
|
|
213
|
+
See [ROADMAP.md](./ROADMAP.md) for planned additions.
|
|
214
|
+
|
|
215
|
+
**Returns:**
|
|
216
|
+
|
|
217
|
+
| Field | Type | Notes |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `connections` | `Connection[]` | linked accounts (tokens redacted) |
|
|
220
|
+
| `job` | `Job \| null` | current restream job |
|
|
221
|
+
| `viewers` | `{ total, platforms }` | polled viewer counts |
|
|
222
|
+
| `comments` | `Comment[]` | live merged chat (SSE) |
|
|
223
|
+
| `error` | `Error \| null` | last error |
|
|
224
|
+
| `connect(platform)` | `Promise` | hosted OAuth popup |
|
|
225
|
+
| `disconnect(platform)` | `Promise` | |
|
|
226
|
+
| `listConnections()` | `Promise<Connection[]>` | |
|
|
227
|
+
| `arm({ destinations })` | — | pre-select destinations |
|
|
228
|
+
| `goLive(params)` | `Promise<Job>` | see [Going live](#going-live) |
|
|
229
|
+
| `stopLive()` | `Promise` | |
|
|
230
|
+
| `refreshJob()` | `Promise<Job>` | re-fetch job status |
|
|
231
|
+
| `sendChat(message, platforms?)` | `Promise` | outbound chat fan-out |
|
|
232
|
+
| `updateOverlay(opts)` | `Promise` | live banner update |
|
|
233
|
+
| `studio` | `RestreamStudio` | the underlying controller |
|
|
234
|
+
|
|
235
|
+
### Convenience hooks
|
|
236
|
+
`useConnections(options)`, `useLiveChat(studio)`, `useViewers(studio)`.
|
|
237
|
+
|
|
238
|
+
### Framework-agnostic core
|
|
239
|
+
Don't use React? Import the controller directly:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
import { RestreamStudio } from "restream-sdk/core";
|
|
243
|
+
const studio = new RestreamStudio({ apiBase, publishableKey, userId });
|
|
244
|
+
studio.on("comments", (c) => …);
|
|
245
|
+
await studio.connect("twitch");
|
|
246
|
+
await studio.goLive({ publisherSessionId, destinations: ["twitch"] });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
TypeScript types ship with the package.
|
|
250
|
+
|
|
251
|
+
## Scopes
|
|
252
|
+
|
|
253
|
+
A publishable key (or token) carries scopes; the operator sets these when creating it:
|
|
254
|
+
|
|
255
|
+
| Scope | Allows |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `connect` | manage social connections |
|
|
258
|
+
| `restream` | start/stop a job + overlay updates |
|
|
259
|
+
| `read` | job status, viewers, comments |
|
|
260
|
+
| `chat:write` | send chat |
|
|
261
|
+
|
|
262
|
+
A 403 with *"Missing required scope"* means your key needs that scope added.
|
|
263
|
+
|
|
264
|
+
## Framework notes
|
|
265
|
+
|
|
266
|
+
- **React 17+** (hooks). The package is ESM and side-effect-free (tree-shakeable).
|
|
267
|
+
- **Next.js / SSR:** use the hooks in a Client Component (`"use client"`). `connect()`
|
|
268
|
+
uses popups + `window`, so it runs in the browser only.
|
|
269
|
+
- The controller uses `fetch`, `EventSource`, and `window.open` — all standard browser APIs.
|
|
270
|
+
|
|
271
|
+
## Troubleshooting
|
|
272
|
+
|
|
273
|
+
| Symptom | Cause / fix |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `connect()` does nothing | Popup blocked — allow popups for your origin. |
|
|
276
|
+
| `Origin not allowed for this key` (403) | Your app's origin isn't on the key's allowlist — ask the operator to add it. |
|
|
277
|
+
| `failed to fetch` on calls | Origin/key mismatch, or wrong `apiBase`. |
|
|
278
|
+
| Job goes `running` but nothing on the platforms | The `publisherSessionId` must be a **live** session that's actively sending media when you `goLive`. |
|
|
279
|
+
| Starting a 2nd stream stops the first | Expected — **one live stream per streamer**; a new `goLive` supersedes the old. |
|
|
280
|
+
| `Missing required scope` (403) | Key needs that scope (`connect` / `restream` / `read` / `chat:write`). |
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
Questions or a key request? Contact your Restream Service operator.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "restream-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers) with zero client backend.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"types": "types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./types/index.d.ts",
|
|
12
|
+
"import": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./core": {
|
|
15
|
+
"types": "./types/core.d.ts",
|
|
16
|
+
"import": "./src/core.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": ["src", "types", "README.md", "LICENSE"],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=17"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"react": { "optional": true }
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Ravant-Technologies/restream-fomogg.git",
|
|
33
|
+
"directory": "packages/restream-sdk"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://fomo.gg",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/Ravant-Technologies/restream-fomogg/issues"
|
|
38
|
+
},
|
|
39
|
+
"author": "Ravant Technologies FZCO",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"keywords": ["restream", "streaming", "twitch", "kick", "youtube", "multistream", "react", "sdk"]
|
|
42
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Framework-agnostic controller for the Restream "connect + control" plane.
|
|
2
|
+
// The browser talks to the Restream API directly with a publishable key (or a
|
|
3
|
+
// short-lived token); no client backend required. The media SESSION
|
|
4
|
+
// (publisherSessionId) is produced by the client's existing stream and passed
|
|
5
|
+
// into goLive() — this module never captures or publishes media itself.
|
|
6
|
+
|
|
7
|
+
export const PLATFORMS = ["twitch", "kick", "youtube", "facebook", "instagram"];
|
|
8
|
+
|
|
9
|
+
const TERMINAL_STATUS = new Set(["stopped", "failed", "ended", "error", "completed"]);
|
|
10
|
+
|
|
11
|
+
export class RestreamStudio extends EventTarget {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.apiBase Base URL of the Restream API (e.g. https://api.restream.example)
|
|
15
|
+
* @param {string} [opts.publishableKey] pk_live_... (zero-backend mode)
|
|
16
|
+
* @param {string} [opts.tokenEndpoint] URL on the client's backend returning { token } (hardened mode)
|
|
17
|
+
* @param {string} [opts.userId] External streamer id (required in publishableKey mode)
|
|
18
|
+
* @param {number} [opts.timeoutMs=15000] Per-request timeout
|
|
19
|
+
*/
|
|
20
|
+
constructor({ apiBase, publishableKey, tokenEndpoint, userId, timeoutMs = 15000 } = {}) {
|
|
21
|
+
super();
|
|
22
|
+
if (!apiBase) throw new Error("apiBase is required");
|
|
23
|
+
if (!publishableKey && !tokenEndpoint) throw new Error("publishableKey or tokenEndpoint is required");
|
|
24
|
+
this.apiBase = apiBase.replace(/\/+$/, "");
|
|
25
|
+
this._apiOrigin = (() => { try { return new URL(this.apiBase).origin; } catch { return null; } })();
|
|
26
|
+
this.publishableKey = publishableKey || null;
|
|
27
|
+
this.tokenEndpoint = tokenEndpoint || null;
|
|
28
|
+
this.userId = userId || null;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
|
|
31
|
+
this.connections = [];
|
|
32
|
+
this.job = null;
|
|
33
|
+
this.viewers = null;
|
|
34
|
+
this.comments = [];
|
|
35
|
+
|
|
36
|
+
this._token = null;
|
|
37
|
+
this._tokenExp = 0;
|
|
38
|
+
this._es = null;
|
|
39
|
+
this._pollTimer = null;
|
|
40
|
+
this._sseReopen = null;
|
|
41
|
+
this._armed = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
|
|
45
|
+
|
|
46
|
+
/** Subscribe to an event ("connections" | "job" | "viewers" | "comment" | "comments" | "error"). Returns an unsubscribe fn. */
|
|
47
|
+
on(type, cb) {
|
|
48
|
+
const handler = (e) => cb(e.detail);
|
|
49
|
+
this.addEventListener(type, handler);
|
|
50
|
+
return () => this.removeEventListener(type, handler);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_timeoutSignal() {
|
|
54
|
+
try { return AbortSignal.timeout(this.timeoutMs); } catch { return undefined; } // older browsers: no timeout
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- auth ----
|
|
58
|
+
async _getToken() {
|
|
59
|
+
if (this.publishableKey) return null;
|
|
60
|
+
const now = Date.now() / 1000;
|
|
61
|
+
if (this._token && this._tokenExp - 30 > now) return this._token;
|
|
62
|
+
const res = await fetch(this.tokenEndpoint, { credentials: "include", signal: this._timeoutSignal() });
|
|
63
|
+
if (!res.ok) throw new Error(`token endpoint failed: ${res.status}`);
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
this._token = data.token;
|
|
66
|
+
try { this._tokenExp = JSON.parse(atob(this._token.split(".")[1])).exp; } catch { this._tokenExp = now + 300; }
|
|
67
|
+
return this._token;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async _authHeaders() {
|
|
71
|
+
if (this.publishableKey) return { "X-Restream-Key": this.publishableKey };
|
|
72
|
+
return { Authorization: `Bearer ${await this._getToken()}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _authQuery() {
|
|
76
|
+
if (this.publishableKey) {
|
|
77
|
+
const q = { pk: this.publishableKey };
|
|
78
|
+
if (this.userId) q.userId = this.userId;
|
|
79
|
+
return q;
|
|
80
|
+
}
|
|
81
|
+
return { token: await this._getToken() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_userQuery() {
|
|
85
|
+
return this.publishableKey && this.userId ? `?userId=${encodeURIComponent(this.userId)}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async _req(method, path, body, _retried = false) {
|
|
89
|
+
const headers = { ...(await this._authHeaders()) };
|
|
90
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(`${this.apiBase}${path}`, {
|
|
94
|
+
method,
|
|
95
|
+
headers,
|
|
96
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
97
|
+
signal: this._timeoutSignal(),
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
const err = new Error(e?.name === "TimeoutError" ? `request timed out after ${this.timeoutMs}ms` : (e?.message || "network error"));
|
|
101
|
+
err.status = 0;
|
|
102
|
+
this._emit("error", err);
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
// Token expired mid-session (hardened mode): drop the cached token and retry once.
|
|
106
|
+
if (res.status === 401 && this.tokenEndpoint && !_retried) {
|
|
107
|
+
this._token = null; this._tokenExp = 0;
|
|
108
|
+
return this._req(method, path, body, true);
|
|
109
|
+
}
|
|
110
|
+
if (res.status === 204) return null;
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
let data;
|
|
113
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const err = new Error((data && data.error) || `HTTP ${res.status}`);
|
|
116
|
+
err.status = res.status;
|
|
117
|
+
this._emit("error", err);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- social connections ----
|
|
124
|
+
async listConnections() {
|
|
125
|
+
this.connections = (await this._req("GET", `/api/v1/connect/list${this._userQuery()}`)) || [];
|
|
126
|
+
this._emit("connections", this.connections);
|
|
127
|
+
return this.connections;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Open the hosted OAuth popup for a platform; resolves when connected (or the popup closes). */
|
|
131
|
+
async connect(platform) {
|
|
132
|
+
const q = await this._authQuery();
|
|
133
|
+
const url = `${this.apiBase}/api/v1/connect/${platform}/start?${new URLSearchParams(q)}`;
|
|
134
|
+
const popup = window.open(url, "restream_connect", "width=560,height=760");
|
|
135
|
+
if (!popup) throw new Error("popup blocked — allow popups to connect accounts");
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
window.removeEventListener("message", onMsg);
|
|
139
|
+
clearInterval(timer);
|
|
140
|
+
};
|
|
141
|
+
const onMsg = (e) => {
|
|
142
|
+
// Only trust messages from our own callback origin.
|
|
143
|
+
if (this._apiOrigin && e.origin !== this._apiOrigin) return;
|
|
144
|
+
const d = e.data;
|
|
145
|
+
if (!d || typeof d !== "object" || d.platform !== platform) return;
|
|
146
|
+
if (d.type === "restream:connected") { cleanup(); this.listConnections().catch(() => {}); resolve({ platform }); }
|
|
147
|
+
else if (d.type === "restream:error") { cleanup(); reject(new Error(d.error || "connect failed")); }
|
|
148
|
+
};
|
|
149
|
+
const timer = setInterval(() => {
|
|
150
|
+
if (popup.closed) { cleanup(); this.listConnections().catch(() => {}); resolve({ platform, closed: true }); }
|
|
151
|
+
}, 800);
|
|
152
|
+
window.addEventListener("message", onMsg);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async disconnect(platform) {
|
|
157
|
+
await this._req("DELETE", `/api/v1/connect/${platform}${this._userQuery()}`);
|
|
158
|
+
return this.listConnections();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- restream control ----
|
|
162
|
+
/** Pre-select default destinations used by goLive() when none are passed. */
|
|
163
|
+
arm(opts) { this._armed = opts || null; return this._armed; }
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Start restreaming an existing stream session to social destinations.
|
|
167
|
+
* @param {object} opts
|
|
168
|
+
* @param {string} opts.publisherSessionId The session from the client's existing stream (REQUIRED).
|
|
169
|
+
* @param {string[]} [opts.destinations] e.g. ["twitch","kick"] (defaults to armed destinations).
|
|
170
|
+
* @param {object} [opts.overlay] { topBanner?, bottomBanner?, htmlBanner?, enabled? }
|
|
171
|
+
* @param {boolean} [opts.recording]
|
|
172
|
+
* @param {string[]} [opts.trackNames] tracks actually published, e.g. ["audio","video"]
|
|
173
|
+
*/
|
|
174
|
+
async goLive({ publisherSessionId, destinations, overlay, recording, trackNames } = {}) {
|
|
175
|
+
if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
|
|
176
|
+
const body = {
|
|
177
|
+
publisherSessionId,
|
|
178
|
+
destinations: destinations || this._armed?.destinations || [],
|
|
179
|
+
overlay,
|
|
180
|
+
recording,
|
|
181
|
+
};
|
|
182
|
+
if (trackNames && trackNames.length) body.trackNames = trackNames;
|
|
183
|
+
if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
|
|
184
|
+
const job = await this._req("POST", "/api/v1/jobs", body);
|
|
185
|
+
this.job = job;
|
|
186
|
+
this._emit("job", job);
|
|
187
|
+
if (job?.id) this.startRealtime(job.id);
|
|
188
|
+
return job;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async stopLive() {
|
|
192
|
+
if (!this.job?.id) return null;
|
|
193
|
+
const r = await this._req("DELETE", `/api/v1/jobs/${this.job.id}`);
|
|
194
|
+
this.stopRealtime();
|
|
195
|
+
this.job = { ...this.job, status: "stopping" };
|
|
196
|
+
this._emit("job", this.job);
|
|
197
|
+
return r;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async refreshJob() {
|
|
201
|
+
if (!this.job?.id) return null;
|
|
202
|
+
this.job = await this._req("GET", `/api/v1/jobs/${this.job.id}`);
|
|
203
|
+
this._emit("job", this.job);
|
|
204
|
+
return this.job;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async sendChat(message, platforms) {
|
|
208
|
+
if (!this.job?.id) throw new Error("not live");
|
|
209
|
+
return this._req("POST", `/api/v1/jobs/${this.job.id}/comments`, { message, platforms });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async updateOverlay(opts) {
|
|
213
|
+
if (!this.job?.id) throw new Error("not live");
|
|
214
|
+
return this._req("PATCH", `/api/v1/jobs/${this.job.id}/overlay`, opts);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- realtime (chat SSE + job-status/viewer polling) ----
|
|
218
|
+
async startRealtime(jobId) {
|
|
219
|
+
this.stopRealtime();
|
|
220
|
+
await this._openSse(jobId);
|
|
221
|
+
|
|
222
|
+
// Poll job status (so job.status tracks queued→running→stopped) + viewers.
|
|
223
|
+
let tick = 0;
|
|
224
|
+
const poll = async () => {
|
|
225
|
+
tick++;
|
|
226
|
+
try {
|
|
227
|
+
const j = await this._req("GET", `/api/v1/jobs/${jobId}`);
|
|
228
|
+
if (j) {
|
|
229
|
+
this.job = j;
|
|
230
|
+
this._emit("job", j);
|
|
231
|
+
if (TERMINAL_STATUS.has(j.status)) { this.stopRealtime(); return; }
|
|
232
|
+
}
|
|
233
|
+
} catch { /* transient — retry next tick */ }
|
|
234
|
+
if (tick % 2 === 1) { // viewers ~every other tick (~10s)
|
|
235
|
+
try { this.viewers = await this._req("GET", `/api/v1/jobs/${jobId}/viewers`); this._emit("viewers", this.viewers); } catch { /* non-critical */ }
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
poll();
|
|
239
|
+
this._pollTimer = setInterval(poll, 5000);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async _openSse(jobId) {
|
|
243
|
+
if (this._es) { this._es.close(); this._es = null; }
|
|
244
|
+
const q = await this._authQuery();
|
|
245
|
+
const es = new EventSource(`${this.apiBase}/api/v1/jobs/${jobId}/comments/stream?${new URLSearchParams(q)}`);
|
|
246
|
+
es.onmessage = (e) => {
|
|
247
|
+
let c;
|
|
248
|
+
try { c = JSON.parse(e.data); } catch { return; } // heartbeat / non-JSON
|
|
249
|
+
if (!c || (c.id && this.comments.some((x) => x.id === c.id))) return; // dedup (history replays on reconnect)
|
|
250
|
+
this.comments = [...this.comments, c].slice(-200);
|
|
251
|
+
this._emit("comment", c);
|
|
252
|
+
this._emit("comments", this.comments);
|
|
253
|
+
};
|
|
254
|
+
es.onerror = () => {
|
|
255
|
+
// Transient drop — non-fatal. pk mode auto-reconnects natively; token mode
|
|
256
|
+
// carries a token in the URL that can expire, so reopen with a fresh one.
|
|
257
|
+
if (this.tokenEndpoint && this._pollTimer && !this._sseReopen) {
|
|
258
|
+
this._sseReopen = setTimeout(() => { this._sseReopen = null; if (this._pollTimer) this._openSse(jobId); }, 3000);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
this._es = es;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
stopRealtime() {
|
|
265
|
+
if (this._es) { this._es.close(); this._es = null; }
|
|
266
|
+
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
|
267
|
+
if (this._sseReopen) { clearTimeout(this._sseReopen); this._sseReopen = null; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
destroy() { this.stopRealtime(); }
|
|
271
|
+
}
|
package/src/index.js
ADDED
package/src/react.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import { RestreamStudio } from "./core.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Headless React binding for the Restream connect + control plane.
|
|
6
|
+
* Wire your own UI to the returned state and methods.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts { apiBase, publishableKey?|tokenEndpoint?, userId? }
|
|
9
|
+
*/
|
|
10
|
+
export function useRestream(opts) {
|
|
11
|
+
const ref = useRef(null);
|
|
12
|
+
if (!ref.current) ref.current = new RestreamStudio(opts);
|
|
13
|
+
const studio = ref.current;
|
|
14
|
+
|
|
15
|
+
const [connections, setConnections] = useState([]);
|
|
16
|
+
const [job, setJob] = useState(null);
|
|
17
|
+
const [viewers, setViewers] = useState(null);
|
|
18
|
+
const [comments, setComments] = useState([]);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const offs = [
|
|
23
|
+
studio.on("connections", setConnections),
|
|
24
|
+
studio.on("job", setJob),
|
|
25
|
+
studio.on("viewers", setViewers),
|
|
26
|
+
studio.on("comments", setComments),
|
|
27
|
+
studio.on("error", setError),
|
|
28
|
+
];
|
|
29
|
+
studio.listConnections().catch(() => {});
|
|
30
|
+
return () => { offs.forEach((off) => off()); studio.destroy(); };
|
|
31
|
+
}, [studio]);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
studio,
|
|
35
|
+
connections,
|
|
36
|
+
job,
|
|
37
|
+
viewers,
|
|
38
|
+
comments,
|
|
39
|
+
error,
|
|
40
|
+
connect: useCallback((p) => studio.connect(p), [studio]),
|
|
41
|
+
disconnect: useCallback((p) => studio.disconnect(p), [studio]),
|
|
42
|
+
listConnections: useCallback(() => studio.listConnections(), [studio]),
|
|
43
|
+
arm: useCallback((o) => studio.arm(o), [studio]),
|
|
44
|
+
goLive: useCallback((o) => studio.goLive(o), [studio]),
|
|
45
|
+
stopLive: useCallback(() => studio.stopLive(), [studio]),
|
|
46
|
+
refreshJob: useCallback(() => studio.refreshJob(), [studio]),
|
|
47
|
+
sendChat: useCallback((m, p) => studio.sendChat(m, p), [studio]),
|
|
48
|
+
updateOverlay: useCallback((o) => studio.updateOverlay(o), [studio]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Convenience: just the social-connection state + actions. */
|
|
53
|
+
export function useConnections(opts) {
|
|
54
|
+
const { connections, connect, disconnect, listConnections } = useRestream(opts);
|
|
55
|
+
return { connections, connect, disconnect, refresh: listConnections };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Convenience: live chat (incoming comments + outbound send). Pass an existing studio. */
|
|
59
|
+
export function useLiveChat(studio) {
|
|
60
|
+
const [comments, setComments] = useState(studio?.comments || []);
|
|
61
|
+
useEffect(() => (studio ? studio.on("comments", setComments) : undefined), [studio]);
|
|
62
|
+
const send = useCallback((m, p) => studio?.sendChat(m, p), [studio]);
|
|
63
|
+
return { comments, send };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Convenience: viewer counts for the active job. Pass an existing studio. */
|
|
67
|
+
export function useViewers(studio) {
|
|
68
|
+
const [viewers, setViewers] = useState(studio?.viewers || null);
|
|
69
|
+
useEffect(() => (studio ? studio.on("viewers", setViewers) : undefined), [studio]);
|
|
70
|
+
return viewers;
|
|
71
|
+
}
|
package/types/core.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Types for the framework-agnostic core (`restream-sdk/core`).
|
|
2
|
+
export { RestreamStudio, PLATFORMS } from "./index";
|
|
3
|
+
export type {
|
|
4
|
+
Platform,
|
|
5
|
+
RestreamOptions,
|
|
6
|
+
Connection,
|
|
7
|
+
Job,
|
|
8
|
+
ViewerCounts,
|
|
9
|
+
Comment,
|
|
10
|
+
Overlay,
|
|
11
|
+
GoLiveParams,
|
|
12
|
+
OverlayUpdate,
|
|
13
|
+
RestreamEvent,
|
|
14
|
+
} from "./index";
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type Platform = "twitch" | "kick" | "youtube" | "facebook" | "instagram";
|
|
2
|
+
export const PLATFORMS: Platform[];
|
|
3
|
+
|
|
4
|
+
export interface RestreamOptions {
|
|
5
|
+
/** Base URL of the Restream API. */
|
|
6
|
+
apiBase: string;
|
|
7
|
+
/** Publishable key (pk_live_...) for zero-backend mode. */
|
|
8
|
+
publishableKey?: string;
|
|
9
|
+
/** URL on your backend returning `{ token }` (hardened mode). */
|
|
10
|
+
tokenEndpoint?: string;
|
|
11
|
+
/** External streamer id (required in publishableKey mode). */
|
|
12
|
+
userId?: string;
|
|
13
|
+
/** Per-request timeout in ms (default 15000). */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Connection {
|
|
18
|
+
platform: Platform;
|
|
19
|
+
platformUsername?: string;
|
|
20
|
+
platformChannelId?: string;
|
|
21
|
+
connectedAt?: string;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Job {
|
|
26
|
+
id: string;
|
|
27
|
+
status: string;
|
|
28
|
+
destinations?: Platform[];
|
|
29
|
+
broadcasts?: Array<{ platform: Platform; watchUrl?: string }>;
|
|
30
|
+
[k: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ViewerCounts {
|
|
34
|
+
total?: number;
|
|
35
|
+
platforms?: Partial<Record<Platform, number>>;
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Comment {
|
|
40
|
+
id: string;
|
|
41
|
+
platform: Platform;
|
|
42
|
+
author: string;
|
|
43
|
+
message: string;
|
|
44
|
+
timestamp?: number;
|
|
45
|
+
authorAvatar?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Overlay {
|
|
49
|
+
topBanner?: string;
|
|
50
|
+
bottomBanner?: string;
|
|
51
|
+
htmlBanner?: string;
|
|
52
|
+
enabled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GoLiveParams {
|
|
56
|
+
/** The session id from your existing stream (REQUIRED). */
|
|
57
|
+
publisherSessionId: string;
|
|
58
|
+
destinations?: Platform[];
|
|
59
|
+
overlay?: Overlay;
|
|
60
|
+
recording?: boolean;
|
|
61
|
+
/** Track names actually published (e.g. ["audio","video"] or ["video"]). */
|
|
62
|
+
trackNames?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OverlayUpdate {
|
|
66
|
+
topBanner?: string;
|
|
67
|
+
bottomBanner?: string;
|
|
68
|
+
hideTop?: boolean;
|
|
69
|
+
hideBottom?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RestreamEvent = "connections" | "job" | "viewers" | "comment" | "comments" | "error";
|
|
73
|
+
|
|
74
|
+
export class RestreamStudio extends EventTarget {
|
|
75
|
+
constructor(opts: RestreamOptions);
|
|
76
|
+
connections: Connection[];
|
|
77
|
+
job: Job | null;
|
|
78
|
+
viewers: ViewerCounts | null;
|
|
79
|
+
comments: Comment[];
|
|
80
|
+
on(type: "connections", cb: (c: Connection[]) => void): () => void;
|
|
81
|
+
on(type: "job", cb: (j: Job) => void): () => void;
|
|
82
|
+
on(type: "viewers", cb: (v: ViewerCounts) => void): () => void;
|
|
83
|
+
on(type: "comment", cb: (c: Comment) => void): () => void;
|
|
84
|
+
on(type: "comments", cb: (c: Comment[]) => void): () => void;
|
|
85
|
+
on(type: "error", cb: (e: Error) => void): () => void;
|
|
86
|
+
listConnections(): Promise<Connection[]>;
|
|
87
|
+
connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
|
|
88
|
+
disconnect(platform: Platform): Promise<Connection[]>;
|
|
89
|
+
arm(opts: { destinations: Platform[] }): unknown;
|
|
90
|
+
goLive(opts: GoLiveParams): Promise<Job>;
|
|
91
|
+
stopLive(): Promise<unknown>;
|
|
92
|
+
refreshJob(): Promise<Job | null>;
|
|
93
|
+
sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
|
|
94
|
+
updateOverlay(opts: OverlayUpdate): Promise<unknown>;
|
|
95
|
+
startRealtime(jobId: string): Promise<void>;
|
|
96
|
+
stopRealtime(): void;
|
|
97
|
+
destroy(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UseRestream {
|
|
101
|
+
studio: RestreamStudio;
|
|
102
|
+
connections: Connection[];
|
|
103
|
+
job: Job | null;
|
|
104
|
+
viewers: ViewerCounts | null;
|
|
105
|
+
comments: Comment[];
|
|
106
|
+
error: Error | null;
|
|
107
|
+
connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
|
|
108
|
+
disconnect(platform: Platform): Promise<Connection[]>;
|
|
109
|
+
listConnections(): Promise<Connection[]>;
|
|
110
|
+
arm(opts: { destinations: Platform[] }): unknown;
|
|
111
|
+
goLive(opts: GoLiveParams): Promise<Job>;
|
|
112
|
+
stopLive(): Promise<unknown>;
|
|
113
|
+
refreshJob(): Promise<Job | null>;
|
|
114
|
+
sendChat(message: string, platforms?: Platform[]): Promise<unknown>;
|
|
115
|
+
updateOverlay(opts: OverlayUpdate): Promise<unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useRestream(opts: RestreamOptions): UseRestream;
|
|
119
|
+
export function useConnections(opts: RestreamOptions): {
|
|
120
|
+
connections: Connection[];
|
|
121
|
+
connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
|
|
122
|
+
disconnect(platform: Platform): Promise<Connection[]>;
|
|
123
|
+
refresh(): Promise<Connection[]>;
|
|
124
|
+
};
|
|
125
|
+
export function useLiveChat(studio: RestreamStudio): { comments: Comment[]; send(message: string, platforms?: Platform[]): Promise<unknown> };
|
|
126
|
+
export function useViewers(studio: RestreamStudio): ViewerCounts | null;
|