@strevokit/meeting-sdk 0.0.0-stage → 0.8.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.
@@ -0,0 +1,370 @@
1
+ # Backend API for meeting clients
2
+
3
+ Your backend prepares each meeting with the Strevokit HTTP API, then mints a short-lived **join token** for the
4
+ browser. The meeting SDK only ever receives that token — never your API credential.
5
+
6
+ Base URL in the examples: `https://api.example.com` (replace with your Strevokit API host).
7
+
8
+ ## Auth
9
+
10
+ Every route below requires:
11
+
12
+ ```http
13
+ Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
14
+ Content-Type: application/json
15
+ ```
16
+
17
+ Keep `STREVOKIT_API_CREDENTIAL` on the server only. Never put it in browser code, `VITE_*` / `NEXT_PUBLIC_*`
18
+ variables, or mobile apps.
19
+
20
+ Meeting and participant identifiers in paths and JSON are opaque `public_id` values (26-character Crockford ULIDs).
21
+ Responses never include numeric internal database ids.
22
+
23
+ ## Route index
24
+
25
+ | Method | Path | Purpose |
26
+ |--------|------|---------|
27
+ | `POST` | `/v1/embed-origins` | Allow-list a browser origin that may join |
28
+ | `GET` | `/v1/embed-origins` | List allow-listed origins |
29
+ | `DELETE` | `/v1/embed-origins/{public_id}` | Remove an allow-listed origin |
30
+ | `POST` | `/v1/meetings` | Create a meeting |
31
+ | `GET` | `/v1/meetings/{public_id}` | Fetch a meeting |
32
+ | `POST` | `/v1/meetings/{public_id}/participants` | Authorize (register) a participant |
33
+ | `POST` | `/v1/meetings/{public_id}/join-token` | Mint a join token for one participant |
34
+ | `POST` | `/v1/meetings/{public_id}/end` | End the meeting for everyone |
35
+
36
+ ## Integration sequence
37
+
38
+ 1. **Allow-list your app origin** (`POST /v1/embed-origins`) once per environment (for example `https://app.example.com`).
39
+ Joins from other origins fail with `origin_not_allowed` at the realtime handshake.
40
+ 2. **Create a meeting** (`POST /v1/meetings`) when your product starts a session. Store `data.public_id`.
41
+ 3. **Authorize each person** (`POST /v1/meetings/{public_id}/participants`) as `host` or `participant`. Store each
42
+ `data.public_id` (the participant public id).
43
+ 4. **Mint a join token** (`POST /v1/meetings/{public_id}/join-token`) when that user opens the meeting page. Return
44
+ only `data.token` to the browser.
45
+ 5. **Join in the browser** with `createMeetingEngine({ token, realtimeUrl })` (see the [README](../README.md)).
46
+
47
+ Optional later: `GET /v1/meetings/{public_id}` to read status; `POST /v1/meetings/{public_id}/end` to end from your
48
+ backend.
49
+
50
+ ---
51
+
52
+ ## POST /v1/embed-origins
53
+
54
+ Register one browser origin that may open meetings.
55
+
56
+ ```http
57
+ POST /v1/embed-origins
58
+ Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
59
+ Content-Type: application/json
60
+ ```
61
+
62
+ ```json
63
+ { "origin": "https://app.example.com" }
64
+ ```
65
+
66
+ `origin` must be a full web origin (`http` or `https`, host, optional non-default port). No path, query, fragment, or
67
+ wildcards.
68
+
69
+ **Success:** `201` when newly inserted, `200` when the same origin already exists.
70
+
71
+ ```json
72
+ {
73
+ "data": {
74
+ "public_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
75
+ "origin": "https://app.example.com",
76
+ "created_at": "2026-09-24T12:00:00.000000Z"
77
+ }
78
+ }
79
+ ```
80
+
81
+ Also available: `GET /v1/embed-origins` (list) and `DELETE /v1/embed-origins/{public_id}` (`204` on success). An empty
82
+ allow-list means joins fail closed until you add at least one origin.
83
+
84
+ ---
85
+
86
+ ## POST /v1/meetings
87
+
88
+ Create a meeting.
89
+
90
+ ```http
91
+ POST /v1/meetings
92
+ Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
93
+ Content-Type: application/json
94
+ Idempotency-Key: <optional opaque string>
95
+ ```
96
+
97
+ ```json
98
+ {
99
+ "type": "one_to_one",
100
+ "max_participants": 2,
101
+ "recording_requested": false,
102
+ "external_reference": "session_9876",
103
+ "empty_timeout_seconds": 300
104
+ }
105
+ ```
106
+
107
+ | Field | Required | Notes |
108
+ |-------|----------|--------|
109
+ | `type` | yes | `one_to_one` or `one_to_many` |
110
+ | `max_participants` | yes | `2` for `one_to_one`; `3`–`50` for `one_to_many` |
111
+ | `recording_requested` | no | Default `false`. Alias `recording` is accepted |
112
+ | `scheduled_start` | no | ISO-8601 with timezone; stored as `scheduled_at` |
113
+ | `metadata` | no | Flat JSON object; keep it small |
114
+ | `external_reference` | no | Your own id, 1–255 characters when present; echoed unchanged |
115
+ | `empty_timeout_seconds` | no | `60`–`86400`; default `300`. Idle empty meetings end after this many seconds |
116
+
117
+ Do not send `tenant_id`, numeric `id`, or a client-chosen media mode — the platform selects delivery.
118
+
119
+ Optional `Idempotency-Key` (≤255 characters): same key + same body replays the first response; same key + different
120
+ body returns `409` `idempotency_conflict`.
121
+
122
+ **Success:** `201 Created` (idempotent replay may return the original status).
123
+
124
+ ```json
125
+ {
126
+ "data": {
127
+ "public_id": "01JB2Q8ABCDEF01234567890AB",
128
+ "external_reference": "session_9876",
129
+ "type": "one_to_one",
130
+ "max_participants": 2,
131
+ "recording_requested": false,
132
+ "recording_allowed": false,
133
+ "status": "created",
134
+ "scheduled_at": null,
135
+ "started_at": null,
136
+ "ended_at": null,
137
+ "end_reason": null,
138
+ "empty_timeout_seconds": 300,
139
+ "metadata": null,
140
+ "created_at": "2026-09-22T12:00:00+00:00",
141
+ "updated_at": "2026-09-22T12:00:00+00:00"
142
+ }
143
+ }
144
+ ```
145
+
146
+ The response may also include platform-selected fields (for example how media is delivered). Store `public_id` for
147
+ later calls.
148
+
149
+ | Case | HTTP | `error.code` |
150
+ |------|------|--------------|
151
+ | Missing or invalid credential | 401 | `unauthorized` |
152
+ | Bad or missing fields | 422 | `validation_failed` |
153
+ | Idempotency key reused with a different body | 409 | `idempotency_conflict` |
154
+ | Rate limited | 429 | `rate_limited` (+ `Retry-After`) |
155
+ | Suspended tenant | 403 | `forbidden` |
156
+
157
+ ---
158
+
159
+ ## GET /v1/meetings/{public_id}
160
+
161
+ Fetch the meeting resource for your tenant (same shape as create). Missing or other-tenant ids return `404`
162
+ `meeting_not_found`.
163
+
164
+ ---
165
+
166
+ ## POST /v1/meetings/{public_id}/participants
167
+
168
+ Authorize (register) a participant on a meeting. Call once per person who may join.
169
+
170
+ ```http
171
+ POST /v1/meetings/{public_id}/participants
172
+ Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
173
+ Content-Type: application/json
174
+ Idempotency-Key: <optional opaque string>
175
+ ```
176
+
177
+ ```json
178
+ {
179
+ "external_user_id": "user_42",
180
+ "display_name": "Alex",
181
+ "role": "host"
182
+ }
183
+ ```
184
+
185
+ | Field | Required | Notes |
186
+ |-------|----------|--------|
187
+ | `external_user_id` | yes | Your user id within this meeting (1–255 chars; not whitespace-only) |
188
+ | `display_name` | yes | Shown name (1–255 chars; not whitespace-only) |
189
+ | `role` | yes | `host` or `participant` |
190
+
191
+ **Upsert:** the same `external_user_id` on the same meeting returns the same participant `public_id`, updates name and
192
+ role, and does not consume an extra capacity slot. A **new** identity is rejected with `422`
193
+ `participant_limit_exceeded` when the meeting already has `max_participants` distinct people.
194
+
195
+ **Display names:** Pass the name users should see in the roster as `display_name` when you authorize. The join-token
196
+ route and the browser (including this SDK) do not accept a name. If no usable name is available at join, the UI shows
197
+ **Guest**. To rename someone, re-authorize with the same `external_user_id` and a new `display_name`, then mint a new
198
+ join token and have them join again.
199
+
200
+ Optional `Idempotency-Key` behaves like create (same key + different body → `409` `idempotency_conflict`).
201
+
202
+ **Success:** `201` on first insert, `200` on upsert of an existing identity (idempotent replay returns the stored
203
+ status and body).
204
+
205
+ ```json
206
+ {
207
+ "data": {
208
+ "public_id": "01JQ4ABCDEF01234567890ABCD",
209
+ "external_user_id": "user_42",
210
+ "display_name": "Alex",
211
+ "role": "host",
212
+ "status": "invited"
213
+ }
214
+ }
215
+ ```
216
+
217
+ | Case | HTTP | `error.code` |
218
+ |------|------|--------------|
219
+ | Missing or invalid credential | 401 | `unauthorized` |
220
+ | Bad or missing fields | 422 | `validation_failed` |
221
+ | Meeting missing or other tenant | 404 | `meeting_not_found` |
222
+ | Meeting already `ended` | 409 | `meeting_ended` |
223
+ | Meeting `cancelled` / `failed` | 403 | `forbidden` |
224
+ | New identity over capacity | 422 | `participant_limit_exceeded` |
225
+ | Idempotency conflict | 409 | `idempotency_conflict` |
226
+ | Rate limited | 429 | `rate_limited` (+ `Retry-After`) |
227
+
228
+ ---
229
+
230
+ ## POST /v1/meetings/{public_id}/join-token
231
+
232
+ Mint a short-lived join token for one authorized participant. Mint a **new** token for every join (including rejoin
233
+ after leave). Do **not** send `Idempotency-Key` — each successful call issues a new token.
234
+
235
+ ```http
236
+ POST /v1/meetings/{public_id}/join-token
237
+ Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
238
+ Content-Type: application/json
239
+ ```
240
+
241
+ ```json
242
+ { "participant_public_id": "01JQ4ABCDEF01234567890ABCD" }
243
+ ```
244
+
245
+ | Field | Required | Notes |
246
+ |-------|----------|--------|
247
+ | `participant_public_id` | yes | From authorize; must belong to this meeting |
248
+
249
+ Do not send `role`, `display_name`, `tenant_id`, or numeric `id`. Role and display name come from the authorized
250
+ participant row.
251
+
252
+ **Success:** `201 Created`
253
+
254
+ ```json
255
+ {
256
+ "data": {
257
+ "token": "<compact JWT>",
258
+ "expires_at": "2026-09-24T15:05:00Z",
259
+ "expires_in": 300
260
+ }
261
+ }
262
+ ```
263
+
264
+ Default TTL is 300 seconds. Treat `token` like a secret: do not log it, put it in URLs, or cache it (`Cache-Control:
265
+ no-store` on your app response). Return only the token string to the browser.
266
+
267
+ | Case | HTTP | `error.code` |
268
+ |------|------|--------------|
269
+ | Missing or invalid credential | 401 | `unauthorized` |
270
+ | Bad or missing `participant_public_id` | 422 | `validation_failed` |
271
+ | Meeting missing or other tenant | 404 | `meeting_not_found` |
272
+ | Participant missing or not on this meeting | 404 | `participant_not_found` |
273
+ | Meeting already `ended` | 409 | `meeting_ended` |
274
+ | Meeting `cancelled` / `failed` | 403 | `forbidden` |
275
+ | Join-token rate limited | 429 | `rate_limited` (+ `Retry-After`) |
276
+
277
+ Realtime handshake failures (after you hand the token to the SDK) include `invalid_join_token`, `join_token_expired`,
278
+ and `origin_not_allowed`.
279
+
280
+ ---
281
+
282
+ ## POST /v1/meetings/{public_id}/end
283
+
284
+ End the meeting for everyone from your backend. Optional `Idempotency-Key`. Success returns the updated meeting
285
+ resource with `status` `ended` and `end_reason` `tenant_api`.
286
+
287
+ ---
288
+
289
+ ## Errors
290
+
291
+ Error bodies use a stable `error.code` string (and a message). On `429`, honor `Retry-After`. Prefer mapping known
292
+ codes in your backend rather than parsing message text.
293
+
294
+ ## End-to-end example (Node.js)
295
+
296
+ ```ts
297
+ const STREVOKIT_API = "https://api.example.com";
298
+ const API_CREDENTIAL = process.env.STREVOKIT_API_CREDENTIAL!;
299
+
300
+ async function strevokit(path: string, init: RequestInit = {}) {
301
+ const response = await fetch(`${STREVOKIT_API}${path}`, {
302
+ ...init,
303
+ headers: {
304
+ Authorization: `Bearer ${API_CREDENTIAL}`,
305
+ "Content-Type": "application/json",
306
+ ...(init.headers ?? {}),
307
+ },
308
+ });
309
+ if (!response.ok) throw new Error(`Strevokit ${path} → HTTP ${response.status}`);
310
+ if (response.status === 204) return null;
311
+ return response.json();
312
+ }
313
+
314
+ // Once per environment:
315
+ await strevokit("/v1/embed-origins", {
316
+ method: "POST",
317
+ body: JSON.stringify({ origin: "https://app.example.com" }),
318
+ });
319
+
320
+ // When your product starts a session:
321
+ const { data: meeting } = await strevokit("/v1/meetings", {
322
+ method: "POST",
323
+ headers: { "Idempotency-Key": crypto.randomUUID() },
324
+ body: JSON.stringify({
325
+ type: "one_to_one",
326
+ max_participants: 2,
327
+ external_reference: "booking_123",
328
+ }),
329
+ });
330
+
331
+ const { data: host } = await strevokit(`/v1/meetings/${meeting.public_id}/participants`, {
332
+ method: "POST",
333
+ body: JSON.stringify({
334
+ external_user_id: "user_host",
335
+ display_name: "Alex",
336
+ role: "host",
337
+ }),
338
+ });
339
+
340
+ const { data: guest } = await strevokit(`/v1/meetings/${meeting.public_id}/participants`, {
341
+ method: "POST",
342
+ body: JSON.stringify({
343
+ external_user_id: "user_guest",
344
+ display_name: "Sam",
345
+ role: "participant",
346
+ }),
347
+ });
348
+
349
+ // When that signed-in user opens the meeting page:
350
+ const { data: issued } = await strevokit(`/v1/meetings/${meeting.public_id}/join-token`, {
351
+ method: "POST",
352
+ body: JSON.stringify({ participant_public_id: host.public_id }),
353
+ });
354
+ // Return issued.token to the browser only.
355
+ ```
356
+
357
+ ## Hand the token to the SDK
358
+
359
+ ```ts
360
+ import { createMeetingEngine } from "@strevokit/meeting-sdk";
361
+
362
+ const engine = createMeetingEngine({
363
+ token, // from your backend
364
+ realtimeUrl: "wss://realtime.example.com",
365
+ });
366
+ await engine.join();
367
+ ```
368
+
369
+ A longer Express-style proxy that creates, authorizes, and mints is in
370
+ [examples/backend-integration.md](./examples/backend-integration.md).
@@ -0,0 +1,103 @@
1
+ # Backend integration (create, authorize, join token)
2
+
3
+ The meeting SDK needs a join token for each participant. Tokens are short-lived and single-use. Only your backend can
4
+ mint them, because minting needs your Strevokit API credential.
5
+
6
+ Before minting, your backend usually **creates** the meeting and **authorizes** participants. The full HTTP reference
7
+ (methods, paths, bodies, errors) is in [Backend API](../backend-api.md).
8
+
9
+ **Keep the API credential on your server.** Never ship it in browser code, environment variables exposed to the
10
+ browser (such as `VITE_*` or `NEXT_PUBLIC_*`), or mobile apps. The browser only ever receives the join token.
11
+
12
+ ## Flow
13
+
14
+ 1. (Once) Allow-list the origin that hosts your meeting page (`POST /v1/embed-origins`).
15
+ 2. When your product starts a session, create a meeting (`POST /v1/meetings`) and authorize each person
16
+ (`POST /v1/meetings/{public_id}/participants`).
17
+ 3. The signed-in user opens a meeting page in your app.
18
+ 4. The page asks your backend for a join token.
19
+ 5. Your backend checks that this user may join, then calls `POST /v1/meetings/{public_id}/join-token`.
20
+ 6. Your backend returns only the token to the page, which passes it to `createMeetingEngine({ token, realtimeUrl })`.
21
+
22
+ ## Example (Node.js with Express)
23
+
24
+ ```ts
25
+ import express from "express";
26
+
27
+ const app = express();
28
+ app.use(express.json());
29
+
30
+ const STREVOKIT_API = "https://api.example.com"; // your Strevokit API base URL
31
+ const API_CREDENTIAL = process.env.STREVOKIT_API_CREDENTIAL; // server-side secret, never sent to browsers
32
+
33
+ const strevokitHeaders = {
34
+ Authorization: `Bearer ${API_CREDENTIAL}`,
35
+ "Content-Type": "application/json",
36
+ };
37
+
38
+ // Once per environment (run from a setup script or admin path):
39
+ // await fetch(`${STREVOKIT_API}/v1/embed-origins`, {
40
+ // method: "POST",
41
+ // headers: strevokitHeaders,
42
+ // body: JSON.stringify({ origin: "https://app.example.com" }),
43
+ // });
44
+
45
+ app.post("/api/meetings", requireSignedInUser, async (req, res) => {
46
+ const response = await fetch(`${STREVOKIT_API}/v1/meetings`, {
47
+ method: "POST",
48
+ headers: { ...strevokitHeaders, "Idempotency-Key": crypto.randomUUID() },
49
+ body: JSON.stringify({
50
+ type: "one_to_one",
51
+ max_participants: 2,
52
+ external_reference: req.body.bookingId,
53
+ }),
54
+ });
55
+ if (!response.ok) return res.sendStatus(502);
56
+ const { data: meeting } = await response.json();
57
+
58
+ const hostResponse = await fetch(`${STREVOKIT_API}/v1/meetings/${meeting.public_id}/participants`, {
59
+ method: "POST",
60
+ headers: strevokitHeaders,
61
+ body: JSON.stringify({
62
+ external_user_id: String(req.user.id),
63
+ display_name: req.user.displayName,
64
+ role: "host",
65
+ }),
66
+ });
67
+ if (!hostResponse.ok) return res.sendStatus(502);
68
+ const { data: host } = await hostResponse.json();
69
+
70
+ // Persist meeting.public_id and host.public_id in your own database, keyed by your booking / room.
71
+ res.status(201).json({ meetingPublicId: meeting.public_id, participantPublicId: host.public_id });
72
+ });
73
+
74
+ app.post("/api/meetings/:meetingId/join-token", requireSignedInUser, async (req, res) => {
75
+ // Your own authorization: may this user join this meeting, and as which Strevokit participant?
76
+ const participantPublicId = await participantFor(req.user, req.params.meetingId);
77
+ if (!participantPublicId) return res.sendStatus(403);
78
+
79
+ const response = await fetch(
80
+ `${STREVOKIT_API}/v1/meetings/${encodeURIComponent(req.params.meetingId)}/join-token`,
81
+ {
82
+ method: "POST",
83
+ headers: strevokitHeaders,
84
+ body: JSON.stringify({ participant_public_id: participantPublicId }),
85
+ },
86
+ );
87
+ if (!response.ok) return res.sendStatus(502);
88
+
89
+ const { data } = await response.json(); // { token, expires_at, expires_in }
90
+ res.set("Cache-Control", "no-store").json({ token: data.token });
91
+ });
92
+ ```
93
+
94
+ `requireSignedInUser` and `participantFor` stand for your own session and authorization code.
95
+
96
+ ## Notes
97
+
98
+ - Create and authorize before the first join-token call; join-token needs an existing participant `public_id`.
99
+ - Pass a real `display_name` on authorize; join-token cannot set or change it. If the UI shows **Guest**, authorize
100
+ used a missing or placeholder name, or that step was skipped.
101
+ - Mint a new token for every join, including rejoining after `leave()` / `dispose()`.
102
+ - Send the join-token response with `Cache-Control: no-store` so the token isn't cached.
103
+ - Don't log the token or put it in a URL. The SDK keeps it in memory only.
@@ -0,0 +1,144 @@
1
+ // A minimal React integration: one hook that owns the engine, plus a few buttons. Not a UI kit.
2
+ import { useEffect, useReducer, useRef, useState } from "react";
3
+ import {
4
+ createMeetingEngine,
5
+ MeetingError,
6
+ type ConnectionState,
7
+ type MeetingEngine,
8
+ } from "@strevokit/meeting-sdk";
9
+
10
+ export function useMeetingEngine(token: string, realtimeUrl: string) {
11
+ const [engine, setEngine] = useState<MeetingEngine | null>(null);
12
+ const [state, setState] = useState<ConnectionState>("idle");
13
+ const [error, setError] = useState<MeetingError | null>(null);
14
+ const [, refresh] = useReducer((n: number) => n + 1, 0);
15
+
16
+ useEffect(() => {
17
+ setError(null);
18
+ setState("idle");
19
+ let next: MeetingEngine;
20
+ try {
21
+ next = createMeetingEngine({ token, realtimeUrl });
22
+ } catch (reason) {
23
+ if (!(reason instanceof MeetingError)) throw reason;
24
+ setError(reason);
25
+ setEngine(null);
26
+ return;
27
+ }
28
+ const unsubscribe = [
29
+ next.on("state_changed", (event) => setState(event.state)),
30
+ next.on("joined", refresh),
31
+ next.on("participant_joined", refresh),
32
+ next.on("participant_left", refresh),
33
+ next.on("participant_media_changed", refresh),
34
+ next.on("local_media_changed", refresh),
35
+ next.on("error", (event) => setError(event.error)),
36
+ next.on("closed", (event) => setError(event.error ?? null)),
37
+ ];
38
+ setEngine(next);
39
+ next.join().catch((reason: unknown) => {
40
+ if (reason instanceof MeetingError && reason.code !== "join_cancelled") setError(reason);
41
+ });
42
+ return () => {
43
+ for (const off of unsubscribe) off();
44
+ next.dispose();
45
+ };
46
+ }, [token, realtimeUrl]);
47
+
48
+ return { engine, state, error };
49
+ }
50
+
51
+ function SelfPreview({ stream }: { stream: MediaStream | null }) {
52
+ const ref = useRef<HTMLVideoElement>(null);
53
+ useEffect(() => {
54
+ if (ref.current) ref.current.srcObject = stream;
55
+ }, [stream]);
56
+ return <video ref={ref} autoPlay muted playsInline width={320} />;
57
+ }
58
+
59
+ function RemoteParticipant({ engine, participantId }: { engine: MeetingEngine; participantId: string }) {
60
+ const video = useRef<HTMLVideoElement>(null);
61
+ const audio = useRef<HTMLAudioElement>(null);
62
+ const [status, setStatus] = useState(() => engine.mediaConnectionStatus(participantId));
63
+
64
+ useEffect(() => {
65
+ const attach = () => {
66
+ const media = engine.remoteMedia(participantId);
67
+ if (video.current) video.current.srcObject = media.camera;
68
+ if (audio.current) audio.current.srcObject = media.microphone;
69
+ };
70
+ attach();
71
+ const unsubscribe = [
72
+ engine.on("remote_media_changed", (event) => {
73
+ if (event.participantId === participantId) attach();
74
+ }),
75
+ engine.on("media_connection_changed", (event) => {
76
+ if (event.participantId === participantId) setStatus(event.status);
77
+ }),
78
+ ];
79
+ return () => {
80
+ for (const off of unsubscribe) off();
81
+ };
82
+ }, [engine, participantId]);
83
+
84
+ return (
85
+ <figure>
86
+ <video ref={video} autoPlay muted playsInline width={320} />
87
+ <audio ref={audio} autoPlay />
88
+ <figcaption>{status === "interrupted" ? "Connection lost — reconnecting…" : status}</figcaption>
89
+ </figure>
90
+ );
91
+ }
92
+
93
+ export function Meeting({ token, realtimeUrl }: { token: string; realtimeUrl: string }) {
94
+ const { engine, state, error } = useMeetingEngine(token, realtimeUrl);
95
+ if (!engine) return error ? <p role="alert">{error.message}</p> : null;
96
+
97
+ const media = engine.localMediaState;
98
+ const run = (action: () => Promise<void>) => {
99
+ action().catch((reason: unknown) => {
100
+ if (reason instanceof MeetingError) alert(reason.message);
101
+ });
102
+ };
103
+
104
+ return (
105
+ <section>
106
+ <p>{error ? error.message : state}</p>
107
+ <SelfPreview stream={engine.localMedia.camera} />
108
+ <div role="group" aria-label="Media controls">
109
+ <button
110
+ aria-pressed={media.camera}
111
+ disabled={state !== "joined"}
112
+ onClick={() => run(() => (media.camera ? engine.disableCamera() : engine.enableCamera()))}
113
+ >
114
+ Camera
115
+ </button>
116
+ <button
117
+ aria-pressed={media.microphone}
118
+ disabled={state !== "joined"}
119
+ onClick={() => run(() => (media.microphone ? engine.mute() : engine.unmute()))}
120
+ >
121
+ Microphone
122
+ </button>
123
+ <button
124
+ aria-pressed={media.screen}
125
+ disabled={state !== "joined"}
126
+ onClick={() => run(() => (media.screen ? engine.stopScreenShare() : engine.startScreenShare()))}
127
+ >
128
+ Share screen
129
+ </button>
130
+ <button disabled={state !== "joined"} onClick={() => void engine.leave()}>
131
+ Leave
132
+ </button>
133
+ </div>
134
+ <ul>
135
+ {engine.participants.map((participant) => (
136
+ <li key={participant.id}>
137
+ {participant.displayName ?? participant.id} ({participant.role})
138
+ <RemoteParticipant engine={engine} participantId={participant.id} />
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ </section>
143
+ );
144
+ }