@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/dist/index.js CHANGED
@@ -222,8 +222,15 @@ var WhepViewTransport = class {
222
222
 
223
223
  // src/internal/scale-view-transport.ts
224
224
  var HlsViewTransport = class {
225
- constructor(signaling) {
225
+ /**
226
+ * deliveryPath, when given, is a gateway-relative path from the gateway's own
227
+ * delivery list — that is how a CDN-backed playlist gets used instead of the
228
+ * origin one. Without it this falls back to the origin playlist, which is
229
+ * still correct, just served from our own bandwidth.
230
+ */
231
+ constructor(signaling, deliveryPath) {
226
232
  this.signaling = signaling;
233
+ this.deliveryPath = deliveryPath;
227
234
  this.hls = null;
228
235
  this.video = null;
229
236
  this.endedCb = null;
@@ -237,7 +244,7 @@ var HlsViewTransport = class {
237
244
  }
238
245
  async start(streamId, video) {
239
246
  this.video = video;
240
- const url = this.signaling.scalePlaylistUrl(streamId);
247
+ const url = this.deliveryPath ? this.signaling.deliveryUrl(this.deliveryPath) : this.signaling.scalePlaylistUrl(streamId);
241
248
  video.addEventListener("ended", () => this.endedCb?.());
242
249
  video.addEventListener("waiting", () => this.bufferingCb?.());
243
250
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
@@ -283,30 +290,195 @@ var HlsViewTransport = class {
283
290
  }
284
291
  };
285
292
 
293
+ // src/internal/balanced-view-transport.ts
294
+ var FlvViewTransport = class {
295
+ constructor(signaling, deliveryPath) {
296
+ this.signaling = signaling;
297
+ this.deliveryPath = deliveryPath;
298
+ this.player = null;
299
+ this.video = null;
300
+ this.endedCb = null;
301
+ this.bufferingCb = null;
302
+ }
303
+ onEnded(cb) {
304
+ this.endedCb = cb;
305
+ }
306
+ onBuffering(cb) {
307
+ this.bufferingCb = cb;
308
+ }
309
+ async start(_streamId, video) {
310
+ this.video = video;
311
+ const url = this.signaling.deliveryUrl(this.deliveryPath);
312
+ video.addEventListener("ended", () => this.endedCb?.());
313
+ video.addEventListener("waiting", () => this.bufferingCb?.());
314
+ let mod;
315
+ try {
316
+ mod = await import("flv.js");
317
+ } catch (cause) {
318
+ throw mebiusError("CONNECTION_FAILED", "Balanced playback support failed to load.", cause);
319
+ }
320
+ const flvjs = mod.default;
321
+ if (!flvjs.isSupported()) {
322
+ throw mebiusError("CONNECTION_FAILED", "Balanced playback is not supported in this browser.");
323
+ }
324
+ const player = flvjs.createPlayer({ type: "flv", url, isLive: true });
325
+ this.player = player;
326
+ player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
327
+ player.attachMediaElement(video);
328
+ player.load();
329
+ await Promise.resolve(player.play()).catch(() => void 0);
330
+ }
331
+ async stop() {
332
+ if (this.player) {
333
+ this.player.unload();
334
+ this.player.detachMediaElement();
335
+ this.player.destroy();
336
+ this.player = null;
337
+ }
338
+ if (this.video) {
339
+ this.video.removeAttribute("src");
340
+ this.video.load();
341
+ }
342
+ this.video = null;
343
+ }
344
+ async getStats() {
345
+ if (!this.video) return null;
346
+ return {
347
+ bitrateKbps: 0,
348
+ framesPerSecond: 0,
349
+ latencyMs: void 0
350
+ };
351
+ }
352
+ };
353
+
286
354
  // src/internal/transport.ts
287
355
  function createPublishTransport(signaling) {
288
356
  return new WhipPublishTransport(signaling);
289
357
  }
290
- function createViewTransport(mode, signaling) {
358
+ var KIND_FAST = "fast";
359
+ var KIND_WIDE = "wide";
360
+ var KIND_LOCAL = "local";
361
+ function canPlayBuffered() {
362
+ return typeof MediaSource !== "undefined";
363
+ }
364
+ function transportFor(kind, path, signaling) {
365
+ if (kind === KIND_FAST) return canPlayBuffered() ? new FlvViewTransport(signaling, path) : null;
366
+ if (kind === KIND_WIDE || kind === KIND_LOCAL) return new HlsViewTransport(signaling, path);
367
+ return null;
368
+ }
369
+ function createViewCandidates(mode, signaling, deliveries = []) {
370
+ const fromGateway = (kinds) => deliveries.filter((d) => kinds.includes(d.kind)).map((d) => transportFor(d.kind, d.path, signaling)).filter((t) => t !== null);
371
+ const originFallback = new HlsViewTransport(signaling);
372
+ const allKinds = [KIND_FAST, KIND_WIDE, KIND_LOCAL];
291
373
  switch (mode) {
292
374
  case "low-latency":
293
- return new WhepViewTransport(signaling);
375
+ return [new WhepViewTransport(signaling), ...fromGateway(allKinds), originFallback];
376
+ case "balanced":
377
+ return [...fromGateway(allKinds), originFallback];
294
378
  case "scale":
295
- return new HlsViewTransport(signaling);
379
+ return [...fromGateway([KIND_WIDE, KIND_LOCAL]), originFallback];
380
+ case "auto":
381
+ return [...fromGateway(allKinds), originFallback];
296
382
  }
297
383
  }
298
384
 
385
+ // src/internal/telemetry.ts
386
+ var SDK_VERSION = "web/0.4.0";
387
+ var FLUSH_INTERVAL_MS = 15e3;
388
+ var MAX_BATCH = 64;
389
+ function describeDevice() {
390
+ const nav = typeof navigator === "undefined" ? void 0 : navigator;
391
+ const uaData = nav?.userAgentData;
392
+ return { os: uaData?.platform || nav?.platform || void 0, sdk: SDK_VERSION };
393
+ }
394
+ function describeNetwork() {
395
+ const conn = typeof navigator === "undefined" ? void 0 : navigator.connection;
396
+ return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
397
+ }
398
+ var QoeReporter = class {
399
+ constructor(target, role, streamId, userId) {
400
+ this.target = target;
401
+ this.role = role;
402
+ this.streamId = streamId;
403
+ this.userId = userId;
404
+ this.sessionId = randomId();
405
+ this.buffer = [];
406
+ this.timer = null;
407
+ this.unloadHandler = null;
408
+ }
409
+ start() {
410
+ if (this.timer) return;
411
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
412
+ if (typeof window !== "undefined") {
413
+ this.unloadHandler = () => void this.flush(true);
414
+ window.addEventListener("pagehide", this.unloadHandler);
415
+ }
416
+ }
417
+ add(sample) {
418
+ this.buffer.push(sample);
419
+ if (this.buffer.length >= MAX_BATCH) void this.flush();
420
+ }
421
+ async stop() {
422
+ if (this.timer) clearInterval(this.timer);
423
+ this.timer = null;
424
+ if (this.unloadHandler && typeof window !== "undefined") {
425
+ window.removeEventListener("pagehide", this.unloadHandler);
426
+ }
427
+ this.unloadHandler = null;
428
+ await this.flush();
429
+ }
430
+ /** Send and clear the buffer. `beacon` uses sendBeacon, for page-unload flushes. */
431
+ async flush(beacon = false) {
432
+ if (!this.buffer.length) return;
433
+ const samples = this.buffer.splice(0, MAX_BATCH);
434
+ const body = JSON.stringify({
435
+ sessionId: this.sessionId,
436
+ streamId: this.streamId,
437
+ role: this.role,
438
+ userId: this.userId,
439
+ samples,
440
+ device: describeDevice(),
441
+ network: describeNetwork()
442
+ });
443
+ if (beacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
444
+ const url = `${this.target.url}${this.target.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.target.token)}`;
445
+ try {
446
+ navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
447
+ } catch {
448
+ }
449
+ return;
450
+ }
451
+ try {
452
+ await fetch(this.target.url, {
453
+ method: "POST",
454
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.target.token}` },
455
+ body,
456
+ keepalive: true
457
+ });
458
+ } catch {
459
+ }
460
+ }
461
+ };
462
+ function randomId() {
463
+ const c = typeof crypto === "undefined" ? void 0 : crypto;
464
+ if (c?.randomUUID) return c.randomUUID();
465
+ return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
466
+ }
467
+
299
468
  // src/broadcaster.ts
300
469
  var STATS_INTERVAL_MS = 2e3;
301
470
  var MebiusBroadcaster = class extends TypedEmitter {
302
471
  /** @internal */
303
- constructor(signaling, options) {
472
+ constructor(signaling, options, telemetry = null, userId) {
304
473
  super();
305
474
  this.options = options;
475
+ this.telemetry = telemetry;
476
+ this.userId = userId;
306
477
  this.stream = null;
307
478
  this.facingMode = "user";
308
479
  this.statsTimer = null;
309
480
  this.started = false;
481
+ this.reporter = null;
310
482
  this.transport = createPublishTransport(signaling);
311
483
  }
312
484
  /** Begin broadcasting under the given stream id. */
@@ -315,12 +487,18 @@ var MebiusBroadcaster = class extends TypedEmitter {
315
487
  this.stream = await this.capture();
316
488
  await this.transport.start(streamId, this.stream);
317
489
  this.started = true;
490
+ if (this.telemetry) {
491
+ this.reporter = new QoeReporter(this.telemetry, "pub", streamId, this.userId);
492
+ this.reporter.start();
493
+ }
318
494
  this.startStats();
319
495
  this.emit("started", { streamId });
320
496
  }
321
497
  /** Stop broadcasting and release the camera/microphone. */
322
498
  async stop() {
323
499
  this.stopStats();
500
+ await this.reporter?.stop();
501
+ this.reporter = null;
324
502
  await this.transport.stop();
325
503
  this.stream?.getTracks().forEach((t) => t.stop());
326
504
  this.stream = null;
@@ -376,7 +554,14 @@ var MebiusBroadcaster = class extends TypedEmitter {
376
554
  startStats() {
377
555
  this.statsTimer = setInterval(async () => {
378
556
  const stats = await this.transport.getStats();
379
- if (stats) this.emit("stats", stats);
557
+ if (!stats) return;
558
+ this.emit("stats", stats);
559
+ this.reporter?.add({
560
+ ts: Math.floor(Date.now() / 1e3),
561
+ bitrateKbps: stats.bitrateKbps,
562
+ fps: stats.framesPerSecond,
563
+ rttMs: stats.rttMs
564
+ });
380
565
  }, STATS_INTERVAL_MS);
381
566
  }
382
567
  stopStats() {
@@ -391,34 +576,59 @@ function normalize(c, fallback) {
391
576
 
392
577
  // src/player.ts
393
578
  var STATS_INTERVAL_MS2 = 2e3;
579
+ var FIRST_FRAME_TIMEOUT_MS = 8e3;
394
580
  var MebiusPlayer = class extends TypedEmitter {
395
581
  /** @internal */
396
- constructor(signaling, options) {
582
+ constructor(signaling, options = {}, deliveries = [], telemetry = null, userId) {
397
583
  super();
584
+ this.telemetry = telemetry;
585
+ this.userId = userId;
586
+ this.transport = null;
398
587
  this.video = null;
399
588
  this.statsTimer = null;
400
589
  this.playing = false;
401
- this.transport = createViewTransport(options.mode, signaling);
402
- this.transport.onEnded(() => {
403
- this.playing = false;
404
- this.stopStats();
405
- this.emit("ended", void 0);
406
- });
407
- this.transport.onBuffering(() => this.emit("buffering", void 0));
590
+ this.reporter = null;
591
+ this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
408
592
  }
409
593
  /** Start playing `streamId` into the given video element or selector. */
410
594
  async play(streamId, viewTarget) {
411
595
  if (this.playing) return;
412
- this.video = resolveVideoElement(viewTarget);
413
- await this.transport.start(streamId, this.video);
414
- this.playing = true;
415
- this.startStats();
416
- this.emit("playing", { streamId });
596
+ const video = resolveVideoElement(viewTarget);
597
+ this.video = video;
598
+ const startedAtMs = Date.now();
599
+ let lastError = null;
600
+ for (const candidate of this.candidates) {
601
+ try {
602
+ this.attach(candidate);
603
+ await candidate.start(streamId, video);
604
+ if (await hasFirstFrame(video)) {
605
+ this.transport = candidate;
606
+ this.playing = true;
607
+ if (this.telemetry) {
608
+ this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
609
+ this.reporter.start();
610
+ this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
611
+ }
612
+ this.startStats();
613
+ this.emit("playing", { streamId });
614
+ return;
615
+ }
616
+ lastError = mebiusError("CONNECTION_FAILED", "A Mebius route delivered no video.");
617
+ } catch (cause) {
618
+ lastError = cause;
619
+ }
620
+ await candidate.stop().catch(() => void 0);
621
+ }
622
+ this.video = null;
623
+ throw lastError ?? mebiusError("CONNECTION_FAILED", "No Mebius route could play this stream.");
417
624
  }
418
625
  /** Stop playback and detach from the video element. */
419
626
  async stop() {
420
627
  this.stopStats();
421
- await this.transport.stop();
628
+ await this.reporter?.stop();
629
+ this.reporter = null;
630
+ await this.transport?.stop();
631
+ this.transport = null;
422
632
  this.video = null;
423
633
  this.playing = false;
424
634
  }
@@ -427,10 +637,30 @@ var MebiusPlayer = class extends TypedEmitter {
427
637
  const v = Math.min(1, Math.max(0, volume));
428
638
  if (this.video) this.video.volume = v;
429
639
  }
640
+ attach(transport) {
641
+ transport.onEnded(() => {
642
+ if (this.transport !== transport) return;
643
+ this.playing = false;
644
+ this.stopStats();
645
+ void this.reporter?.stop();
646
+ this.reporter = null;
647
+ this.emit("ended", void 0);
648
+ });
649
+ transport.onBuffering(() => {
650
+ if (this.transport !== transport) return;
651
+ this.emit("buffering", void 0);
652
+ });
653
+ }
430
654
  startStats() {
431
655
  this.statsTimer = setInterval(async () => {
432
- const stats = await this.transport.getStats();
433
- if (stats) this.emit("stats", stats);
656
+ const stats = await this.transport?.getStats();
657
+ if (!stats) return;
658
+ this.emit("stats", stats);
659
+ this.reporter?.add({
660
+ ts: Math.floor(Date.now() / 1e3),
661
+ bitrateKbps: stats.bitrateKbps,
662
+ fps: stats.framesPerSecond
663
+ });
434
664
  }, STATS_INTERVAL_MS2);
435
665
  }
436
666
  stopStats() {
@@ -438,6 +668,21 @@ var MebiusPlayer = class extends TypedEmitter {
438
668
  this.statsTimer = null;
439
669
  }
440
670
  };
671
+ function hasFirstFrame(video) {
672
+ if (video.currentTime > 0 && !video.paused) return Promise.resolve(true);
673
+ return new Promise((resolve) => {
674
+ const done = (ok) => {
675
+ clearTimeout(timer);
676
+ video.removeEventListener("timeupdate", onTime);
677
+ resolve(ok);
678
+ };
679
+ const onTime = () => {
680
+ if (video.currentTime > 0) done(true);
681
+ };
682
+ const timer = setTimeout(() => done(false), FIRST_FRAME_TIMEOUT_MS);
683
+ video.addEventListener("timeupdate", onTime);
684
+ });
685
+ }
441
686
 
442
687
  // src/internal/signaling.ts
443
688
  var SignalingClient = class {
@@ -461,6 +706,20 @@ var SignalingClient = class {
461
706
  // Build the playlist URL used by scale-mode playback (HLS path, hidden). The
462
707
  // engine serves the playlist under /live/{id}/index.m3u8 and requires the
463
708
  // token in the query; segment URIs in the playlist inherit it automatically.
709
+ /**
710
+ * Absolute, tokenized URL for a gateway-relative delivery path handed to us
711
+ * by the gateway (`deliveries[].path`). The gateway decides which paths exist
712
+ * and in what order; the SDK only resolves them against its own base and
713
+ * attaches the access token. Anything that is not a plain gateway-relative
714
+ * path is rejected rather than fetched: an absolute URL there would send the
715
+ * token to a host we did not choose.
716
+ */
717
+ deliveryUrl(path) {
718
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("://")) {
719
+ throw mebiusError("CONNECTION_FAILED", "The gateway returned an unusable delivery path.");
720
+ }
721
+ return this.withToken(`${this.base()}${path}`);
722
+ }
464
723
  /** Playlist URL for scale-mode playback. */
465
724
  scalePlaylistUrl(streamId) {
466
725
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
@@ -536,9 +795,12 @@ function readToken(token) {
536
795
  // src/client.ts
537
796
  var MebiusClient = class extends TypedEmitter {
538
797
  /** @internal */
539
- constructor(config2, token) {
798
+ constructor(config2, token, deliveries = [], telemetry = null, userId) {
540
799
  super();
541
800
  this.token = token;
801
+ this.deliveries = deliveries;
802
+ this.telemetry = telemetry;
803
+ this.userId = userId;
542
804
  this.expiryTimer = null;
543
805
  this.connected = false;
544
806
  this.signaling = new SignalingClient(config2.gateway, token);
@@ -563,12 +825,27 @@ var MebiusClient = class extends TypedEmitter {
563
825
  /** Create a broadcaster bound to this connection. */
564
826
  createBroadcaster(options = {}) {
565
827
  this.assertConnected();
566
- return new MebiusBroadcaster(this.signaling, options);
828
+ return new MebiusBroadcaster(this.signaling, options, this.telemetry, this.userId);
567
829
  }
568
830
  /** Create a player bound to this connection. */
569
- createPlayer(options) {
831
+ createPlayer(options = {}) {
832
+ this.assertConnected();
833
+ return new MebiusPlayer(this.signaling, options, this.deliveries, this.telemetry, this.userId);
834
+ }
835
+ /**
836
+ * Create a monitor: a player tuned for watching a stream you are interacting
837
+ * WITH rather than merely watching — the other side of a co-broadcast, where a
838
+ * second or two of delay makes the interaction feel broken.
839
+ *
840
+ * It is a player with the delay budget spent differently, not a different API:
841
+ * it starts on the real-time route and falls back on its own if that route
842
+ * delivers no frames. Apps used to hand-roll this (open a real-time view, run a
843
+ * timer, swap players when it stayed black); getting the fallback wrong showed a
844
+ * black frame to a live audience, so it belongs here rather than in every app.
845
+ */
846
+ createMonitor() {
570
847
  this.assertConnected();
571
- return new MebiusPlayer(this.signaling, options);
848
+ return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
572
849
  }
573
850
  /** Close the connection and release resources. */
574
851
  disconnect(reason) {
@@ -601,7 +878,8 @@ var Mebius = {
601
878
  throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
602
879
  }
603
880
  if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
604
- const client = new MebiusClient(config, options.token);
881
+ const telemetry = options.beaconToken && options.beaconUrl ? { token: options.beaconToken, url: options.beaconUrl } : null;
882
+ const client = new MebiusClient(config, options.token, options.deliveries ?? [], telemetry, options.userId);
605
883
  client.open();
606
884
  return client;
607
885
  },