@mebius-io/web 0.2.0 → 0.3.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 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 }); // token from your backend
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.1.0/packages/web/standalone/mebius.min.js`
58
+ `https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.3.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.3.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({ mode: "low-latency" }); // atau "scale"
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,44 @@ player.setVolume(0.8);
118
123
  await player.stop();
119
124
  ```
120
125
 
121
- Ganti mode kapan saja dengan membuat player baru: `mode: "low-latency"` untuk
122
- delay minimum, `mode: "scale"` untuk audiens besar.
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.
123
164
 
124
165
  ## Integrasi per framework
125
166
 
@@ -152,7 +193,7 @@ import { useMebius, usePlayer } from "@mebius-io/react";
152
193
 
153
194
  function Watch({ token, streamId }) {
154
195
  const { client } = useMebius({ appId, gateway, token });
155
- const { videoRef, play } = usePlayer(client, { mode: "low-latency" });
196
+ const { videoRef, play } = usePlayer(client, {});
156
197
  return <video ref={videoRef} onClick={() => play(streamId)} autoPlay />;
157
198
  }
158
199
  ```
@@ -181,7 +222,7 @@ export function useWatch(streamId: string) {
181
222
  onMounted(async () => {
182
223
  Mebius.init({ appId, gateway });
183
224
  const client = Mebius.connect({ token: await getToken() });
184
- player = client.createPlayer({ mode: "low-latency" });
225
+ player = client.createPlayer();
185
226
  await player.play(streamId, video.value!);
186
227
  });
187
228
  onUnmounted(() => player?.stop());
@@ -191,18 +232,19 @@ export function useWatch(streamId: string) {
191
232
 
192
233
  ### Vite
193
234
 
194
- Tidak ada config khusus. ESM langsung jalan; engine playback untuk mode
195
- `"scale"` di-load lazy hanya saat mode itu dipakai, jadi tidak menambah bundle
196
- low-latency.
235
+ Tidak ada config khusus. ESM langsung jalan; engine playback per mode di-load
236
+ lazy hanya saat mode itu dipakai, jadi aplikasi yang cuma pakai `"low-latency"`
237
+ tidak membawa bundle mode lain.
197
238
 
198
239
  ## API Reference
199
240
 
200
241
  | Class | Method | Return | Keterangan |
201
242
  |---|---|---|---|
202
243
  | `Mebius` | `init({ appId, gateway })` | `void` | Konfigurasi sekali di awal. |
203
- | `Mebius` | `connect({ token })` | `MebiusClient` | Buka koneksi. |
244
+ | `Mebius` | `connect({ token, deliveries? })` | `MebiusClient` | Buka koneksi. |
204
245
  | `MebiusClient` | `createBroadcaster({ video?, audio? })` | `MebiusBroadcaster` | |
205
- | `MebiusClient` | `createPlayer({ mode })` | `MebiusPlayer` | `mode: "low-latency" \| "scale"` |
246
+ | `MebiusClient` | `createPlayer({ mode? })` | `MebiusPlayer` | `mode: "auto" \| "low-latency" \| "balanced" \| "scale"`, default `"auto"` |
247
+ | `MebiusClient` | `createMonitor()` | `MebiusPlayer` | Player untuk stream yang kamu ajak interaksi. |
206
248
  | `MebiusClient` | `disconnect(reason?)` | `void` | |
207
249
  | `MebiusBroadcaster` | `start(streamId)` | `Promise<void>` | |
208
250
  | `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
- constructor(signaling) {
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,16 +331,95 @@ 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
- function createViewTransport(mode, signaling) {
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 new HlsViewTransport(signaling);
420
+ return [...fromGateway([KIND_WIDE, KIND_LOCAL]), originFallback];
421
+ case "auto":
422
+ return [...fromGateway(allKinds), originFallback];
337
423
  }
338
424
  }
339
425
 
@@ -432,34 +518,48 @@ function normalize(c, fallback) {
432
518
 
433
519
  // src/player.ts
434
520
  var STATS_INTERVAL_MS2 = 2e3;
521
+ var FIRST_FRAME_TIMEOUT_MS = 8e3;
435
522
  var MebiusPlayer = class extends TypedEmitter {
436
523
  /** @internal */
437
- constructor(signaling, options) {
524
+ constructor(signaling, options = {}, deliveries = []) {
438
525
  super();
526
+ this.transport = null;
439
527
  this.video = null;
440
528
  this.statsTimer = null;
441
529
  this.playing = false;
442
- this.transport = createViewTransport(options.mode, signaling);
443
- this.transport.onEnded(() => {
444
- this.playing = false;
445
- this.stopStats();
446
- this.emit("ended", void 0);
447
- });
448
- this.transport.onBuffering(() => this.emit("buffering", void 0));
530
+ this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
449
531
  }
450
532
  /** Start playing `streamId` into the given video element or selector. */
451
533
  async play(streamId, viewTarget) {
452
534
  if (this.playing) return;
453
- this.video = resolveVideoElement(viewTarget);
454
- await this.transport.start(streamId, this.video);
455
- this.playing = true;
456
- this.startStats();
457
- this.emit("playing", { streamId });
535
+ const video = resolveVideoElement(viewTarget);
536
+ this.video = video;
537
+ let lastError = null;
538
+ for (const candidate of this.candidates) {
539
+ try {
540
+ this.attach(candidate);
541
+ await candidate.start(streamId, video);
542
+ if (await hasFirstFrame(video)) {
543
+ this.transport = candidate;
544
+ this.playing = true;
545
+ this.startStats();
546
+ this.emit("playing", { streamId });
547
+ return;
548
+ }
549
+ lastError = mebiusError("CONNECTION_FAILED", "A Mebius route delivered no video.");
550
+ } catch (cause) {
551
+ lastError = cause;
552
+ }
553
+ await candidate.stop().catch(() => void 0);
554
+ }
555
+ this.video = null;
556
+ throw lastError ?? mebiusError("CONNECTION_FAILED", "No Mebius route could play this stream.");
458
557
  }
459
558
  /** Stop playback and detach from the video element. */
460
559
  async stop() {
461
560
  this.stopStats();
462
- await this.transport.stop();
561
+ await this.transport?.stop();
562
+ this.transport = null;
463
563
  this.video = null;
464
564
  this.playing = false;
465
565
  }
@@ -468,9 +568,21 @@ var MebiusPlayer = class extends TypedEmitter {
468
568
  const v = Math.min(1, Math.max(0, volume));
469
569
  if (this.video) this.video.volume = v;
470
570
  }
571
+ attach(transport) {
572
+ transport.onEnded(() => {
573
+ if (this.transport !== transport) return;
574
+ this.playing = false;
575
+ this.stopStats();
576
+ this.emit("ended", void 0);
577
+ });
578
+ transport.onBuffering(() => {
579
+ if (this.transport !== transport) return;
580
+ this.emit("buffering", void 0);
581
+ });
582
+ }
471
583
  startStats() {
472
584
  this.statsTimer = setInterval(async () => {
473
- const stats = await this.transport.getStats();
585
+ const stats = await this.transport?.getStats();
474
586
  if (stats) this.emit("stats", stats);
475
587
  }, STATS_INTERVAL_MS2);
476
588
  }
@@ -479,6 +591,21 @@ var MebiusPlayer = class extends TypedEmitter {
479
591
  this.statsTimer = null;
480
592
  }
481
593
  };
594
+ function hasFirstFrame(video) {
595
+ if (video.currentTime > 0 && !video.paused) return Promise.resolve(true);
596
+ return new Promise((resolve) => {
597
+ const done = (ok) => {
598
+ clearTimeout(timer);
599
+ video.removeEventListener("timeupdate", onTime);
600
+ resolve(ok);
601
+ };
602
+ const onTime = () => {
603
+ if (video.currentTime > 0) done(true);
604
+ };
605
+ const timer = setTimeout(() => done(false), FIRST_FRAME_TIMEOUT_MS);
606
+ video.addEventListener("timeupdate", onTime);
607
+ });
608
+ }
482
609
 
483
610
  // src/internal/signaling.ts
484
611
  var SignalingClient = class {
@@ -502,6 +629,20 @@ var SignalingClient = class {
502
629
  // Build the playlist URL used by scale-mode playback (HLS path, hidden). The
503
630
  // engine serves the playlist under /live/{id}/index.m3u8 and requires the
504
631
  // token in the query; segment URIs in the playlist inherit it automatically.
632
+ /**
633
+ * Absolute, tokenized URL for a gateway-relative delivery path handed to us
634
+ * by the gateway (`deliveries[].path`). The gateway decides which paths exist
635
+ * and in what order; the SDK only resolves them against its own base and
636
+ * attaches the access token. Anything that is not a plain gateway-relative
637
+ * path is rejected rather than fetched: an absolute URL there would send the
638
+ * token to a host we did not choose.
639
+ */
640
+ deliveryUrl(path) {
641
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("://")) {
642
+ throw mebiusError("CONNECTION_FAILED", "The gateway returned an unusable delivery path.");
643
+ }
644
+ return this.withToken(`${this.base()}${path}`);
645
+ }
505
646
  /** Playlist URL for scale-mode playback. */
506
647
  scalePlaylistUrl(streamId) {
507
648
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
@@ -577,9 +718,10 @@ function readToken(token) {
577
718
  // src/client.ts
578
719
  var MebiusClient = class extends TypedEmitter {
579
720
  /** @internal */
580
- constructor(config2, token) {
721
+ constructor(config2, token, deliveries = []) {
581
722
  super();
582
723
  this.token = token;
724
+ this.deliveries = deliveries;
583
725
  this.expiryTimer = null;
584
726
  this.connected = false;
585
727
  this.signaling = new SignalingClient(config2.gateway, token);
@@ -607,9 +749,24 @@ var MebiusClient = class extends TypedEmitter {
607
749
  return new MebiusBroadcaster(this.signaling, options);
608
750
  }
609
751
  /** Create a player bound to this connection. */
610
- createPlayer(options) {
752
+ createPlayer(options = {}) {
753
+ this.assertConnected();
754
+ return new MebiusPlayer(this.signaling, options, this.deliveries);
755
+ }
756
+ /**
757
+ * Create a monitor: a player tuned for watching a stream you are interacting
758
+ * WITH rather than merely watching — the other side of a co-broadcast, where a
759
+ * second or two of delay makes the interaction feel broken.
760
+ *
761
+ * It is a player with the delay budget spent differently, not a different API:
762
+ * it starts on the real-time route and falls back on its own if that route
763
+ * delivers no frames. Apps used to hand-roll this (open a real-time view, run a
764
+ * timer, swap players when it stayed black); getting the fallback wrong showed a
765
+ * black frame to a live audience, so it belongs here rather than in every app.
766
+ */
767
+ createMonitor() {
611
768
  this.assertConnected();
612
- return new MebiusPlayer(this.signaling, options);
769
+ return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries);
613
770
  }
614
771
  /** Close the connection and release resources. */
615
772
  disconnect(reason) {
@@ -642,7 +799,7 @@ var Mebius = {
642
799
  throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
643
800
  }
644
801
  if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
645
- const client = new MebiusClient(config, options.token);
802
+ const client = new MebiusClient(config, options.token, options.deliveries ?? []);
646
803
  client.open();
647
804
  return client;
648
805
  },