@mebius-io/web 0.2.0 → 0.4.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/README.md +77 -12
- package/dist/index.cjs +306 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -10
- package/dist/index.d.ts +89 -10
- package/dist/index.global.js +9834 -37
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +306 -28
- package/dist/index.js.map +1 -1
- package/package.json +13 -11
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -50,12 +50,17 @@ external deps. `Mebius` becomes a global.
|
|
|
50
50
|
<script src="mebius.min.js"></script>
|
|
51
51
|
<script>
|
|
52
52
|
Mebius.init({ appId: "app_123", gateway: "https://gateway.mebius.io" });
|
|
53
|
-
const client = Mebius.connect({ token }); //
|
|
53
|
+
const client = Mebius.connect({ token, deliveries }); // dari backend kamu
|
|
54
54
|
</script>
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
File + full PHP example: [`standalone/`](./standalone/). Raw download:
|
|
58
|
-
`https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.
|
|
58
|
+
`https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.4.0/packages/web/standalone/mebius.min.js`
|
|
59
|
+
|
|
60
|
+
The drop-in file is not part of the npm package — `files` ships only `dist` — so it
|
|
61
|
+
is fetched from the tag, and the tag must match the version you installed. For a
|
|
62
|
+
page with a build step, or one that can use an import map, prefer the ESM path:
|
|
63
|
+
`https://esm.sh/@mebius-io/web@0.4.0`.
|
|
59
64
|
|
|
60
65
|
## Quick Start
|
|
61
66
|
|
|
@@ -106,7 +111,7 @@ await broadcaster.stop();
|
|
|
106
111
|
### d. Watch
|
|
107
112
|
|
|
108
113
|
```ts
|
|
109
|
-
const player = client.createPlayer(
|
|
114
|
+
const player = client.createPlayer(); // mode default "auto"
|
|
110
115
|
|
|
111
116
|
player.on("playing", ({ streamId }) => console.log("playing", streamId));
|
|
112
117
|
player.on("buffering", () => console.log("buffering..."));
|
|
@@ -118,8 +123,67 @@ player.setVolume(0.8);
|
|
|
118
123
|
await player.stop();
|
|
119
124
|
```
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
### Mode playback
|
|
127
|
+
|
|
128
|
+
| Mode | Kapan dipakai |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `"auto"` (default) | Rekomendasi. Mebius pilih rute per penonton, dan pindah sendiri kalau rute yang dipakai berhenti mengirim frame. |
|
|
131
|
+
| `"low-latency"` | Interaktif dua arah (mis. co-broadcast), delay sub-detik. Browser saja. |
|
|
132
|
+
| `"balanced"` | Delay rendah tapi tetap skala besar. Butuh browser dengan Media Source (bukan Safari iOS). |
|
|
133
|
+
| `"scale"` | Audiens paling besar, delay paling tinggi, jalan di semua platform termasuk Safari iOS. |
|
|
134
|
+
|
|
135
|
+
Ganti mode kapan saja dengan membuat player baru.
|
|
136
|
+
|
|
137
|
+
### Menonton lawan bicara (`createMonitor`)
|
|
138
|
+
|
|
139
|
+
Kalau kamu menonton stream yang sedang kamu **ajak interaksi** (sisi lain dari
|
|
140
|
+
co-broadcast), delay satu-dua detik membuat interaksinya terasa rusak:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const monitor = client.createMonitor();
|
|
144
|
+
await monitor.play(opponentStreamId, "#opponent");
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Sama seperti player biasa, hanya budget delay-nya beda. Monitor mulai dari rute
|
|
148
|
+
real-time dan **pindah sendiri** kalau rute itu tidak mengirim frame dalam 8
|
|
149
|
+
detik — logika yang sebelumnya harus ditulis ulang di setiap aplikasi, dan kalau
|
|
150
|
+
salah hasilnya frame hitam di depan penonton live.
|
|
151
|
+
|
|
152
|
+
### `deliveries`
|
|
153
|
+
|
|
154
|
+
`Mebius.connect()` menerima `deliveries` yang dikirim backend bareng token:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const { token, deliveries } = await (await fetch("/api/mebius-token")).json();
|
|
158
|
+
const client = Mebius.connect({ token, deliveries });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Teruskan apa adanya — isinya opaque dan Mebius yang mengurutkan serta memilih.
|
|
162
|
+
Opsional: tanpa itu playback tetap jalan, tapi setiap penonton dilayani dari
|
|
163
|
+
origin Mebius, bukan edge terdekat.
|
|
164
|
+
|
|
165
|
+
### `beaconToken` / `beaconUrl` — quality di dashboard
|
|
166
|
+
|
|
167
|
+
Response token juga membawa dua field opsional. Teruskan keduanya dan SDK
|
|
168
|
+
melaporkan kualitas stream ini (bitrate, fps, rtt, jeda first-frame) tiap ~15
|
|
169
|
+
detik:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const { token, deliveries, beaconToken, beaconUrl } = await (await fetch("/api/mebius-token")).json();
|
|
173
|
+
const client = Mebius.connect({ token, deliveries, beaconToken, beaconUrl });
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Itu yang mengisi **Quality → Publish / Play** di dashboard Mebius, dan yang jadi
|
|
177
|
+
dasar hitung **viewer minutes** — laporan sisi penonton adalah satu-satunya sumber
|
|
178
|
+
data itu, karena cuma client yang bisa melihat pengalaman penonton sebenarnya.
|
|
179
|
+
|
|
180
|
+
Aman di browser: kredensialnya terikat klaim bertanda tangan ke **satu** stream dan
|
|
181
|
+
**satu** project, jadi tak bisa dipakai menulis telemetri milik stream lain. SDK
|
|
182
|
+
tidak pernah tahu tenant-mu — informasi itu ada di dalam klaim, bukan di kode client.
|
|
183
|
+
|
|
184
|
+
Tanpa dua field itu stream tetap jalan normal; kamu hanya tidak melihat data
|
|
185
|
+
kualitasnya. Tambahkan `userId` di `connect()` kalau ingin laporan itu ikut membawa
|
|
186
|
+
id pengguna versimu.
|
|
123
187
|
|
|
124
188
|
## Integrasi per framework
|
|
125
189
|
|
|
@@ -152,7 +216,7 @@ import { useMebius, usePlayer } from "@mebius-io/react";
|
|
|
152
216
|
|
|
153
217
|
function Watch({ token, streamId }) {
|
|
154
218
|
const { client } = useMebius({ appId, gateway, token });
|
|
155
|
-
const { videoRef, play } = usePlayer(client, {
|
|
219
|
+
const { videoRef, play } = usePlayer(client, {});
|
|
156
220
|
return <video ref={videoRef} onClick={() => play(streamId)} autoPlay />;
|
|
157
221
|
}
|
|
158
222
|
```
|
|
@@ -181,7 +245,7 @@ export function useWatch(streamId: string) {
|
|
|
181
245
|
onMounted(async () => {
|
|
182
246
|
Mebius.init({ appId, gateway });
|
|
183
247
|
const client = Mebius.connect({ token: await getToken() });
|
|
184
|
-
player = client.createPlayer(
|
|
248
|
+
player = client.createPlayer();
|
|
185
249
|
await player.play(streamId, video.value!);
|
|
186
250
|
});
|
|
187
251
|
onUnmounted(() => player?.stop());
|
|
@@ -191,18 +255,19 @@ export function useWatch(streamId: string) {
|
|
|
191
255
|
|
|
192
256
|
### Vite
|
|
193
257
|
|
|
194
|
-
Tidak ada config khusus. ESM langsung jalan; engine playback
|
|
195
|
-
|
|
196
|
-
|
|
258
|
+
Tidak ada config khusus. ESM langsung jalan; engine playback per mode di-load
|
|
259
|
+
lazy hanya saat mode itu dipakai, jadi aplikasi yang cuma pakai `"low-latency"`
|
|
260
|
+
tidak membawa bundle mode lain.
|
|
197
261
|
|
|
198
262
|
## API Reference
|
|
199
263
|
|
|
200
264
|
| Class | Method | Return | Keterangan |
|
|
201
265
|
|---|---|---|---|
|
|
202
266
|
| `Mebius` | `init({ appId, gateway })` | `void` | Konfigurasi sekali di awal. |
|
|
203
|
-
| `Mebius` | `connect({ token })` | `MebiusClient` | Buka koneksi. |
|
|
267
|
+
| `Mebius` | `connect({ token, deliveries? })` | `MebiusClient` | Buka koneksi. |
|
|
204
268
|
| `MebiusClient` | `createBroadcaster({ video?, audio? })` | `MebiusBroadcaster` | |
|
|
205
|
-
| `MebiusClient` | `createPlayer({ mode })` | `MebiusPlayer` | `mode: "low-latency" \| "scale"` |
|
|
269
|
+
| `MebiusClient` | `createPlayer({ mode? })` | `MebiusPlayer` | `mode: "auto" \| "low-latency" \| "balanced" \| "scale"`, default `"auto"` |
|
|
270
|
+
| `MebiusClient` | `createMonitor()` | `MebiusPlayer` | Player untuk stream yang kamu ajak interaksi. |
|
|
206
271
|
| `MebiusClient` | `disconnect(reason?)` | `void` | |
|
|
207
272
|
| `MebiusBroadcaster` | `start(streamId)` | `Promise<void>` | |
|
|
208
273
|
| `MebiusBroadcaster` | `stop()` | `Promise<void>` | |
|
package/dist/index.cjs
CHANGED
|
@@ -263,8 +263,15 @@ var WhepViewTransport = class {
|
|
|
263
263
|
|
|
264
264
|
// src/internal/scale-view-transport.ts
|
|
265
265
|
var HlsViewTransport = class {
|
|
266
|
-
|
|
266
|
+
/**
|
|
267
|
+
* deliveryPath, when given, is a gateway-relative path from the gateway's own
|
|
268
|
+
* delivery list — that is how a CDN-backed playlist gets used instead of the
|
|
269
|
+
* origin one. Without it this falls back to the origin playlist, which is
|
|
270
|
+
* still correct, just served from our own bandwidth.
|
|
271
|
+
*/
|
|
272
|
+
constructor(signaling, deliveryPath) {
|
|
267
273
|
this.signaling = signaling;
|
|
274
|
+
this.deliveryPath = deliveryPath;
|
|
268
275
|
this.hls = null;
|
|
269
276
|
this.video = null;
|
|
270
277
|
this.endedCb = null;
|
|
@@ -278,7 +285,7 @@ var HlsViewTransport = class {
|
|
|
278
285
|
}
|
|
279
286
|
async start(streamId, video) {
|
|
280
287
|
this.video = video;
|
|
281
|
-
const url = this.signaling.scalePlaylistUrl(streamId);
|
|
288
|
+
const url = this.deliveryPath ? this.signaling.deliveryUrl(this.deliveryPath) : this.signaling.scalePlaylistUrl(streamId);
|
|
282
289
|
video.addEventListener("ended", () => this.endedCb?.());
|
|
283
290
|
video.addEventListener("waiting", () => this.bufferingCb?.());
|
|
284
291
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
@@ -324,30 +331,195 @@ var HlsViewTransport = class {
|
|
|
324
331
|
}
|
|
325
332
|
};
|
|
326
333
|
|
|
334
|
+
// src/internal/balanced-view-transport.ts
|
|
335
|
+
var FlvViewTransport = class {
|
|
336
|
+
constructor(signaling, deliveryPath) {
|
|
337
|
+
this.signaling = signaling;
|
|
338
|
+
this.deliveryPath = deliveryPath;
|
|
339
|
+
this.player = null;
|
|
340
|
+
this.video = null;
|
|
341
|
+
this.endedCb = null;
|
|
342
|
+
this.bufferingCb = null;
|
|
343
|
+
}
|
|
344
|
+
onEnded(cb) {
|
|
345
|
+
this.endedCb = cb;
|
|
346
|
+
}
|
|
347
|
+
onBuffering(cb) {
|
|
348
|
+
this.bufferingCb = cb;
|
|
349
|
+
}
|
|
350
|
+
async start(_streamId, video) {
|
|
351
|
+
this.video = video;
|
|
352
|
+
const url = this.signaling.deliveryUrl(this.deliveryPath);
|
|
353
|
+
video.addEventListener("ended", () => this.endedCb?.());
|
|
354
|
+
video.addEventListener("waiting", () => this.bufferingCb?.());
|
|
355
|
+
let mod;
|
|
356
|
+
try {
|
|
357
|
+
mod = await import("flv.js");
|
|
358
|
+
} catch (cause) {
|
|
359
|
+
throw mebiusError("CONNECTION_FAILED", "Balanced playback support failed to load.", cause);
|
|
360
|
+
}
|
|
361
|
+
const flvjs = mod.default;
|
|
362
|
+
if (!flvjs.isSupported()) {
|
|
363
|
+
throw mebiusError("CONNECTION_FAILED", "Balanced playback is not supported in this browser.");
|
|
364
|
+
}
|
|
365
|
+
const player = flvjs.createPlayer({ type: "flv", url, isLive: true });
|
|
366
|
+
this.player = player;
|
|
367
|
+
player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
|
|
368
|
+
player.attachMediaElement(video);
|
|
369
|
+
player.load();
|
|
370
|
+
await Promise.resolve(player.play()).catch(() => void 0);
|
|
371
|
+
}
|
|
372
|
+
async stop() {
|
|
373
|
+
if (this.player) {
|
|
374
|
+
this.player.unload();
|
|
375
|
+
this.player.detachMediaElement();
|
|
376
|
+
this.player.destroy();
|
|
377
|
+
this.player = null;
|
|
378
|
+
}
|
|
379
|
+
if (this.video) {
|
|
380
|
+
this.video.removeAttribute("src");
|
|
381
|
+
this.video.load();
|
|
382
|
+
}
|
|
383
|
+
this.video = null;
|
|
384
|
+
}
|
|
385
|
+
async getStats() {
|
|
386
|
+
if (!this.video) return null;
|
|
387
|
+
return {
|
|
388
|
+
bitrateKbps: 0,
|
|
389
|
+
framesPerSecond: 0,
|
|
390
|
+
latencyMs: void 0
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
327
395
|
// src/internal/transport.ts
|
|
328
396
|
function createPublishTransport(signaling) {
|
|
329
397
|
return new WhipPublishTransport(signaling);
|
|
330
398
|
}
|
|
331
|
-
|
|
399
|
+
var KIND_FAST = "fast";
|
|
400
|
+
var KIND_WIDE = "wide";
|
|
401
|
+
var KIND_LOCAL = "local";
|
|
402
|
+
function canPlayBuffered() {
|
|
403
|
+
return typeof MediaSource !== "undefined";
|
|
404
|
+
}
|
|
405
|
+
function transportFor(kind, path, signaling) {
|
|
406
|
+
if (kind === KIND_FAST) return canPlayBuffered() ? new FlvViewTransport(signaling, path) : null;
|
|
407
|
+
if (kind === KIND_WIDE || kind === KIND_LOCAL) return new HlsViewTransport(signaling, path);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function createViewCandidates(mode, signaling, deliveries = []) {
|
|
411
|
+
const fromGateway = (kinds) => deliveries.filter((d) => kinds.includes(d.kind)).map((d) => transportFor(d.kind, d.path, signaling)).filter((t) => t !== null);
|
|
412
|
+
const originFallback = new HlsViewTransport(signaling);
|
|
413
|
+
const allKinds = [KIND_FAST, KIND_WIDE, KIND_LOCAL];
|
|
332
414
|
switch (mode) {
|
|
333
415
|
case "low-latency":
|
|
334
|
-
return new WhepViewTransport(signaling);
|
|
416
|
+
return [new WhepViewTransport(signaling), ...fromGateway(allKinds), originFallback];
|
|
417
|
+
case "balanced":
|
|
418
|
+
return [...fromGateway(allKinds), originFallback];
|
|
335
419
|
case "scale":
|
|
336
|
-
return
|
|
420
|
+
return [...fromGateway([KIND_WIDE, KIND_LOCAL]), originFallback];
|
|
421
|
+
case "auto":
|
|
422
|
+
return [...fromGateway(allKinds), originFallback];
|
|
337
423
|
}
|
|
338
424
|
}
|
|
339
425
|
|
|
426
|
+
// src/internal/telemetry.ts
|
|
427
|
+
var SDK_VERSION = "web/0.4.0";
|
|
428
|
+
var FLUSH_INTERVAL_MS = 15e3;
|
|
429
|
+
var MAX_BATCH = 64;
|
|
430
|
+
function describeDevice() {
|
|
431
|
+
const nav = typeof navigator === "undefined" ? void 0 : navigator;
|
|
432
|
+
const uaData = nav?.userAgentData;
|
|
433
|
+
return { os: uaData?.platform || nav?.platform || void 0, sdk: SDK_VERSION };
|
|
434
|
+
}
|
|
435
|
+
function describeNetwork() {
|
|
436
|
+
const conn = typeof navigator === "undefined" ? void 0 : navigator.connection;
|
|
437
|
+
return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
|
|
438
|
+
}
|
|
439
|
+
var QoeReporter = class {
|
|
440
|
+
constructor(target, role, streamId, userId) {
|
|
441
|
+
this.target = target;
|
|
442
|
+
this.role = role;
|
|
443
|
+
this.streamId = streamId;
|
|
444
|
+
this.userId = userId;
|
|
445
|
+
this.sessionId = randomId();
|
|
446
|
+
this.buffer = [];
|
|
447
|
+
this.timer = null;
|
|
448
|
+
this.unloadHandler = null;
|
|
449
|
+
}
|
|
450
|
+
start() {
|
|
451
|
+
if (this.timer) return;
|
|
452
|
+
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
|
453
|
+
if (typeof window !== "undefined") {
|
|
454
|
+
this.unloadHandler = () => void this.flush(true);
|
|
455
|
+
window.addEventListener("pagehide", this.unloadHandler);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
add(sample) {
|
|
459
|
+
this.buffer.push(sample);
|
|
460
|
+
if (this.buffer.length >= MAX_BATCH) void this.flush();
|
|
461
|
+
}
|
|
462
|
+
async stop() {
|
|
463
|
+
if (this.timer) clearInterval(this.timer);
|
|
464
|
+
this.timer = null;
|
|
465
|
+
if (this.unloadHandler && typeof window !== "undefined") {
|
|
466
|
+
window.removeEventListener("pagehide", this.unloadHandler);
|
|
467
|
+
}
|
|
468
|
+
this.unloadHandler = null;
|
|
469
|
+
await this.flush();
|
|
470
|
+
}
|
|
471
|
+
/** Send and clear the buffer. `beacon` uses sendBeacon, for page-unload flushes. */
|
|
472
|
+
async flush(beacon = false) {
|
|
473
|
+
if (!this.buffer.length) return;
|
|
474
|
+
const samples = this.buffer.splice(0, MAX_BATCH);
|
|
475
|
+
const body = JSON.stringify({
|
|
476
|
+
sessionId: this.sessionId,
|
|
477
|
+
streamId: this.streamId,
|
|
478
|
+
role: this.role,
|
|
479
|
+
userId: this.userId,
|
|
480
|
+
samples,
|
|
481
|
+
device: describeDevice(),
|
|
482
|
+
network: describeNetwork()
|
|
483
|
+
});
|
|
484
|
+
if (beacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
485
|
+
const url = `${this.target.url}${this.target.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.target.token)}`;
|
|
486
|
+
try {
|
|
487
|
+
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
|
|
488
|
+
} catch {
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
await fetch(this.target.url, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.target.token}` },
|
|
496
|
+
body,
|
|
497
|
+
keepalive: true
|
|
498
|
+
});
|
|
499
|
+
} catch {
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
function randomId() {
|
|
504
|
+
const c = typeof crypto === "undefined" ? void 0 : crypto;
|
|
505
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
506
|
+
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
340
509
|
// src/broadcaster.ts
|
|
341
510
|
var STATS_INTERVAL_MS = 2e3;
|
|
342
511
|
var MebiusBroadcaster = class extends TypedEmitter {
|
|
343
512
|
/** @internal */
|
|
344
|
-
constructor(signaling, options) {
|
|
513
|
+
constructor(signaling, options, telemetry = null, userId) {
|
|
345
514
|
super();
|
|
346
515
|
this.options = options;
|
|
516
|
+
this.telemetry = telemetry;
|
|
517
|
+
this.userId = userId;
|
|
347
518
|
this.stream = null;
|
|
348
519
|
this.facingMode = "user";
|
|
349
520
|
this.statsTimer = null;
|
|
350
521
|
this.started = false;
|
|
522
|
+
this.reporter = null;
|
|
351
523
|
this.transport = createPublishTransport(signaling);
|
|
352
524
|
}
|
|
353
525
|
/** Begin broadcasting under the given stream id. */
|
|
@@ -356,12 +528,18 @@ var MebiusBroadcaster = class extends TypedEmitter {
|
|
|
356
528
|
this.stream = await this.capture();
|
|
357
529
|
await this.transport.start(streamId, this.stream);
|
|
358
530
|
this.started = true;
|
|
531
|
+
if (this.telemetry) {
|
|
532
|
+
this.reporter = new QoeReporter(this.telemetry, "pub", streamId, this.userId);
|
|
533
|
+
this.reporter.start();
|
|
534
|
+
}
|
|
359
535
|
this.startStats();
|
|
360
536
|
this.emit("started", { streamId });
|
|
361
537
|
}
|
|
362
538
|
/** Stop broadcasting and release the camera/microphone. */
|
|
363
539
|
async stop() {
|
|
364
540
|
this.stopStats();
|
|
541
|
+
await this.reporter?.stop();
|
|
542
|
+
this.reporter = null;
|
|
365
543
|
await this.transport.stop();
|
|
366
544
|
this.stream?.getTracks().forEach((t) => t.stop());
|
|
367
545
|
this.stream = null;
|
|
@@ -417,7 +595,14 @@ var MebiusBroadcaster = class extends TypedEmitter {
|
|
|
417
595
|
startStats() {
|
|
418
596
|
this.statsTimer = setInterval(async () => {
|
|
419
597
|
const stats = await this.transport.getStats();
|
|
420
|
-
if (stats)
|
|
598
|
+
if (!stats) return;
|
|
599
|
+
this.emit("stats", stats);
|
|
600
|
+
this.reporter?.add({
|
|
601
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
602
|
+
bitrateKbps: stats.bitrateKbps,
|
|
603
|
+
fps: stats.framesPerSecond,
|
|
604
|
+
rttMs: stats.rttMs
|
|
605
|
+
});
|
|
421
606
|
}, STATS_INTERVAL_MS);
|
|
422
607
|
}
|
|
423
608
|
stopStats() {
|
|
@@ -432,34 +617,59 @@ function normalize(c, fallback) {
|
|
|
432
617
|
|
|
433
618
|
// src/player.ts
|
|
434
619
|
var STATS_INTERVAL_MS2 = 2e3;
|
|
620
|
+
var FIRST_FRAME_TIMEOUT_MS = 8e3;
|
|
435
621
|
var MebiusPlayer = class extends TypedEmitter {
|
|
436
622
|
/** @internal */
|
|
437
|
-
constructor(signaling, options) {
|
|
623
|
+
constructor(signaling, options = {}, deliveries = [], telemetry = null, userId) {
|
|
438
624
|
super();
|
|
625
|
+
this.telemetry = telemetry;
|
|
626
|
+
this.userId = userId;
|
|
627
|
+
this.transport = null;
|
|
439
628
|
this.video = null;
|
|
440
629
|
this.statsTimer = null;
|
|
441
630
|
this.playing = false;
|
|
442
|
-
this.
|
|
443
|
-
this.
|
|
444
|
-
this.playing = false;
|
|
445
|
-
this.stopStats();
|
|
446
|
-
this.emit("ended", void 0);
|
|
447
|
-
});
|
|
448
|
-
this.transport.onBuffering(() => this.emit("buffering", void 0));
|
|
631
|
+
this.reporter = null;
|
|
632
|
+
this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
|
|
449
633
|
}
|
|
450
634
|
/** Start playing `streamId` into the given video element or selector. */
|
|
451
635
|
async play(streamId, viewTarget) {
|
|
452
636
|
if (this.playing) return;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
637
|
+
const video = resolveVideoElement(viewTarget);
|
|
638
|
+
this.video = video;
|
|
639
|
+
const startedAtMs = Date.now();
|
|
640
|
+
let lastError = null;
|
|
641
|
+
for (const candidate of this.candidates) {
|
|
642
|
+
try {
|
|
643
|
+
this.attach(candidate);
|
|
644
|
+
await candidate.start(streamId, video);
|
|
645
|
+
if (await hasFirstFrame(video)) {
|
|
646
|
+
this.transport = candidate;
|
|
647
|
+
this.playing = true;
|
|
648
|
+
if (this.telemetry) {
|
|
649
|
+
this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
|
|
650
|
+
this.reporter.start();
|
|
651
|
+
this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
|
|
652
|
+
}
|
|
653
|
+
this.startStats();
|
|
654
|
+
this.emit("playing", { streamId });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
lastError = mebiusError("CONNECTION_FAILED", "A Mebius route delivered no video.");
|
|
658
|
+
} catch (cause) {
|
|
659
|
+
lastError = cause;
|
|
660
|
+
}
|
|
661
|
+
await candidate.stop().catch(() => void 0);
|
|
662
|
+
}
|
|
663
|
+
this.video = null;
|
|
664
|
+
throw lastError ?? mebiusError("CONNECTION_FAILED", "No Mebius route could play this stream.");
|
|
458
665
|
}
|
|
459
666
|
/** Stop playback and detach from the video element. */
|
|
460
667
|
async stop() {
|
|
461
668
|
this.stopStats();
|
|
462
|
-
await this.
|
|
669
|
+
await this.reporter?.stop();
|
|
670
|
+
this.reporter = null;
|
|
671
|
+
await this.transport?.stop();
|
|
672
|
+
this.transport = null;
|
|
463
673
|
this.video = null;
|
|
464
674
|
this.playing = false;
|
|
465
675
|
}
|
|
@@ -468,10 +678,30 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
468
678
|
const v = Math.min(1, Math.max(0, volume));
|
|
469
679
|
if (this.video) this.video.volume = v;
|
|
470
680
|
}
|
|
681
|
+
attach(transport) {
|
|
682
|
+
transport.onEnded(() => {
|
|
683
|
+
if (this.transport !== transport) return;
|
|
684
|
+
this.playing = false;
|
|
685
|
+
this.stopStats();
|
|
686
|
+
void this.reporter?.stop();
|
|
687
|
+
this.reporter = null;
|
|
688
|
+
this.emit("ended", void 0);
|
|
689
|
+
});
|
|
690
|
+
transport.onBuffering(() => {
|
|
691
|
+
if (this.transport !== transport) return;
|
|
692
|
+
this.emit("buffering", void 0);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
471
695
|
startStats() {
|
|
472
696
|
this.statsTimer = setInterval(async () => {
|
|
473
|
-
const stats = await this.transport
|
|
474
|
-
if (stats)
|
|
697
|
+
const stats = await this.transport?.getStats();
|
|
698
|
+
if (!stats) return;
|
|
699
|
+
this.emit("stats", stats);
|
|
700
|
+
this.reporter?.add({
|
|
701
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
702
|
+
bitrateKbps: stats.bitrateKbps,
|
|
703
|
+
fps: stats.framesPerSecond
|
|
704
|
+
});
|
|
475
705
|
}, STATS_INTERVAL_MS2);
|
|
476
706
|
}
|
|
477
707
|
stopStats() {
|
|
@@ -479,6 +709,21 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
479
709
|
this.statsTimer = null;
|
|
480
710
|
}
|
|
481
711
|
};
|
|
712
|
+
function hasFirstFrame(video) {
|
|
713
|
+
if (video.currentTime > 0 && !video.paused) return Promise.resolve(true);
|
|
714
|
+
return new Promise((resolve) => {
|
|
715
|
+
const done = (ok) => {
|
|
716
|
+
clearTimeout(timer);
|
|
717
|
+
video.removeEventListener("timeupdate", onTime);
|
|
718
|
+
resolve(ok);
|
|
719
|
+
};
|
|
720
|
+
const onTime = () => {
|
|
721
|
+
if (video.currentTime > 0) done(true);
|
|
722
|
+
};
|
|
723
|
+
const timer = setTimeout(() => done(false), FIRST_FRAME_TIMEOUT_MS);
|
|
724
|
+
video.addEventListener("timeupdate", onTime);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
482
727
|
|
|
483
728
|
// src/internal/signaling.ts
|
|
484
729
|
var SignalingClient = class {
|
|
@@ -502,6 +747,20 @@ var SignalingClient = class {
|
|
|
502
747
|
// Build the playlist URL used by scale-mode playback (HLS path, hidden). The
|
|
503
748
|
// engine serves the playlist under /live/{id}/index.m3u8 and requires the
|
|
504
749
|
// token in the query; segment URIs in the playlist inherit it automatically.
|
|
750
|
+
/**
|
|
751
|
+
* Absolute, tokenized URL for a gateway-relative delivery path handed to us
|
|
752
|
+
* by the gateway (`deliveries[].path`). The gateway decides which paths exist
|
|
753
|
+
* and in what order; the SDK only resolves them against its own base and
|
|
754
|
+
* attaches the access token. Anything that is not a plain gateway-relative
|
|
755
|
+
* path is rejected rather than fetched: an absolute URL there would send the
|
|
756
|
+
* token to a host we did not choose.
|
|
757
|
+
*/
|
|
758
|
+
deliveryUrl(path) {
|
|
759
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("://")) {
|
|
760
|
+
throw mebiusError("CONNECTION_FAILED", "The gateway returned an unusable delivery path.");
|
|
761
|
+
}
|
|
762
|
+
return this.withToken(`${this.base()}${path}`);
|
|
763
|
+
}
|
|
505
764
|
/** Playlist URL for scale-mode playback. */
|
|
506
765
|
scalePlaylistUrl(streamId) {
|
|
507
766
|
return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
|
|
@@ -577,9 +836,12 @@ function readToken(token) {
|
|
|
577
836
|
// src/client.ts
|
|
578
837
|
var MebiusClient = class extends TypedEmitter {
|
|
579
838
|
/** @internal */
|
|
580
|
-
constructor(config2, token) {
|
|
839
|
+
constructor(config2, token, deliveries = [], telemetry = null, userId) {
|
|
581
840
|
super();
|
|
582
841
|
this.token = token;
|
|
842
|
+
this.deliveries = deliveries;
|
|
843
|
+
this.telemetry = telemetry;
|
|
844
|
+
this.userId = userId;
|
|
583
845
|
this.expiryTimer = null;
|
|
584
846
|
this.connected = false;
|
|
585
847
|
this.signaling = new SignalingClient(config2.gateway, token);
|
|
@@ -604,12 +866,27 @@ var MebiusClient = class extends TypedEmitter {
|
|
|
604
866
|
/** Create a broadcaster bound to this connection. */
|
|
605
867
|
createBroadcaster(options = {}) {
|
|
606
868
|
this.assertConnected();
|
|
607
|
-
return new MebiusBroadcaster(this.signaling, options);
|
|
869
|
+
return new MebiusBroadcaster(this.signaling, options, this.telemetry, this.userId);
|
|
608
870
|
}
|
|
609
871
|
/** Create a player bound to this connection. */
|
|
610
|
-
createPlayer(options) {
|
|
872
|
+
createPlayer(options = {}) {
|
|
873
|
+
this.assertConnected();
|
|
874
|
+
return new MebiusPlayer(this.signaling, options, this.deliveries, this.telemetry, this.userId);
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Create a monitor: a player tuned for watching a stream you are interacting
|
|
878
|
+
* WITH rather than merely watching — the other side of a co-broadcast, where a
|
|
879
|
+
* second or two of delay makes the interaction feel broken.
|
|
880
|
+
*
|
|
881
|
+
* It is a player with the delay budget spent differently, not a different API:
|
|
882
|
+
* it starts on the real-time route and falls back on its own if that route
|
|
883
|
+
* delivers no frames. Apps used to hand-roll this (open a real-time view, run a
|
|
884
|
+
* timer, swap players when it stayed black); getting the fallback wrong showed a
|
|
885
|
+
* black frame to a live audience, so it belongs here rather than in every app.
|
|
886
|
+
*/
|
|
887
|
+
createMonitor() {
|
|
611
888
|
this.assertConnected();
|
|
612
|
-
return new MebiusPlayer(this.signaling,
|
|
889
|
+
return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
|
|
613
890
|
}
|
|
614
891
|
/** Close the connection and release resources. */
|
|
615
892
|
disconnect(reason) {
|
|
@@ -642,7 +919,8 @@ var Mebius = {
|
|
|
642
919
|
throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
|
|
643
920
|
}
|
|
644
921
|
if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
|
|
645
|
-
const
|
|
922
|
+
const telemetry = options.beaconToken && options.beaconUrl ? { token: options.beaconToken, url: options.beaconUrl } : null;
|
|
923
|
+
const client = new MebiusClient(config, options.token, options.deliveries ?? [], telemetry, options.userId);
|
|
646
924
|
client.open();
|
|
647
925
|
return client;
|
|
648
926
|
},
|