@mebius-io/web 0.3.0 → 0.4.1

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
@@ -55,12 +55,12 @@ external deps. `Mebius` becomes a global.
55
55
  ```
56
56
 
57
57
  File + full PHP example: [`standalone/`](./standalone/). Raw download:
58
- `https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.3.0/packages/web/standalone/mebius.min.js`
58
+ `https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.4.1/packages/web/standalone/mebius.min.js`
59
59
 
60
60
  The drop-in file is not part of the npm package — `files` ships only `dist` — so it
61
61
  is fetched from the tag, and the tag must match the version you installed. For a
62
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`.
63
+ `https://esm.sh/@mebius-io/web@0.4.1`.
64
64
 
65
65
  ## Quick Start
66
66
 
@@ -162,6 +162,29 @@ Teruskan apa adanya — isinya opaque dan Mebius yang mengurutkan serta memilih.
162
162
  Opsional: tanpa itu playback tetap jalan, tapi setiap penonton dilayani dari
163
163
  origin Mebius, bukan edge terdekat.
164
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.
187
+
165
188
  ## Integrasi per framework
166
189
 
167
190
  ### Vanilla JS
package/dist/index.cjs CHANGED
@@ -188,6 +188,28 @@ var WhipPublishTransport = class {
188
188
  }
189
189
  };
190
190
 
191
+ // src/internal/autoplay.ts
192
+ function isAutoplayBlocked(e) {
193
+ return typeof e === "object" && e !== null && e.name === "NotAllowedError";
194
+ }
195
+ async function playWithAutoplayFallback(video) {
196
+ video.playsInline = true;
197
+ try {
198
+ await video.play();
199
+ return { mutedByPolicy: false };
200
+ } catch (e) {
201
+ if (!isAutoplayBlocked(e) || video.muted) throw e;
202
+ video.muted = true;
203
+ await video.play();
204
+ return { mutedByPolicy: true };
205
+ }
206
+ }
207
+ function resetVideoElement(video) {
208
+ video.srcObject = null;
209
+ video.removeAttribute("src");
210
+ video.load();
211
+ }
212
+
191
213
  // src/internal/ll-view-transport.ts
192
214
  var WhepViewTransport = class {
193
215
  constructor(signaling) {
@@ -196,6 +218,10 @@ var WhepViewTransport = class {
196
218
  this.resourceUrl = null;
197
219
  this.endedCb = null;
198
220
  this.bufferingCb = null;
221
+ /** Element this route attached a MediaStream to, so stop() can release it. */
222
+ this.video = null;
223
+ /** True when playback only started because the element had to be muted. */
224
+ this.mutedByPolicy = false;
199
225
  }
200
226
  onEnded(cb) {
201
227
  this.endedCb = cb;
@@ -204,6 +230,7 @@ var WhepViewTransport = class {
204
230
  this.bufferingCb = cb;
205
231
  }
206
232
  async start(streamId, video) {
233
+ this.video = video;
207
234
  const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
208
235
  this.pc = pc;
209
236
  const remote = new MediaStream();
@@ -212,7 +239,9 @@ var WhepViewTransport = class {
212
239
  pc.ontrack = (ev) => {
213
240
  remote.addTrack(ev.track);
214
241
  video.srcObject = remote;
215
- void video.play().catch(() => {
242
+ void playWithAutoplayFallback(video).then((o) => {
243
+ this.mutedByPolicy = o.mutedByPolicy;
244
+ }).catch(() => {
216
245
  });
217
246
  };
218
247
  pc.onconnectionstatechange = () => {
@@ -239,6 +268,8 @@ var WhepViewTransport = class {
239
268
  this.resourceUrl = null;
240
269
  this.pc?.close();
241
270
  this.pc = null;
271
+ if (this.video) resetVideoElement(this.video);
272
+ this.video = null;
242
273
  }
243
274
  async getStats() {
244
275
  if (!this.pc) return null;
@@ -276,6 +307,8 @@ var HlsViewTransport = class {
276
307
  this.video = null;
277
308
  this.endedCb = null;
278
309
  this.bufferingCb = null;
310
+ /** True when playback only started because the element had to be muted. */
311
+ this.mutedByPolicy = false;
279
312
  }
280
313
  onEnded(cb) {
281
314
  this.endedCb = cb;
@@ -290,7 +323,7 @@ var HlsViewTransport = class {
290
323
  video.addEventListener("waiting", () => this.bufferingCb?.());
291
324
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
292
325
  video.src = url;
293
- await video.play().catch(() => void 0);
326
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
294
327
  return;
295
328
  }
296
329
  let mod;
@@ -303,14 +336,14 @@ var HlsViewTransport = class {
303
336
  if (!Hls.isSupported()) {
304
337
  throw mebiusError("CONNECTION_FAILED", "Scale playback is not supported in this browser.");
305
338
  }
306
- const hls = new Hls({ lowLatencyMode: true });
339
+ const hls = new Hls();
307
340
  this.hls = hls;
308
341
  hls.on(Hls.Events.ERROR, (_evt, data) => {
309
342
  if (data.fatal) this.bufferingCb?.();
310
343
  });
311
344
  hls.loadSource(url);
312
345
  hls.attachMedia(video);
313
- await video.play().catch(() => void 0);
346
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
314
347
  }
315
348
  async stop() {
316
349
  this.hls?.destroy();
@@ -340,6 +373,8 @@ var FlvViewTransport = class {
340
373
  this.video = null;
341
374
  this.endedCb = null;
342
375
  this.bufferingCb = null;
376
+ /** True when playback only started because the element had to be muted. */
377
+ this.mutedByPolicy = false;
343
378
  }
344
379
  onEnded(cb) {
345
380
  this.endedCb = cb;
@@ -367,7 +402,7 @@ var FlvViewTransport = class {
367
402
  player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
368
403
  player.attachMediaElement(video);
369
404
  player.load();
370
- await Promise.resolve(player.play()).catch(() => void 0);
405
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
371
406
  }
372
407
  async stop() {
373
408
  if (this.player) {
@@ -423,17 +458,103 @@ function createViewCandidates(mode, signaling, deliveries = []) {
423
458
  }
424
459
  }
425
460
 
461
+ // src/internal/telemetry.ts
462
+ var SDK_VERSION = "web/0.4.1";
463
+ var FLUSH_INTERVAL_MS = 15e3;
464
+ var MAX_BATCH = 64;
465
+ function describeDevice() {
466
+ const nav = typeof navigator === "undefined" ? void 0 : navigator;
467
+ const uaData = nav?.userAgentData;
468
+ return { os: uaData?.platform || nav?.platform || void 0, sdk: SDK_VERSION };
469
+ }
470
+ function describeNetwork() {
471
+ const conn = typeof navigator === "undefined" ? void 0 : navigator.connection;
472
+ return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
473
+ }
474
+ var QoeReporter = class {
475
+ constructor(target, role, streamId, userId) {
476
+ this.target = target;
477
+ this.role = role;
478
+ this.streamId = streamId;
479
+ this.userId = userId;
480
+ this.sessionId = randomId();
481
+ this.buffer = [];
482
+ this.timer = null;
483
+ this.unloadHandler = null;
484
+ }
485
+ start() {
486
+ if (this.timer) return;
487
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
488
+ if (typeof window !== "undefined") {
489
+ this.unloadHandler = () => void this.flush(true);
490
+ window.addEventListener("pagehide", this.unloadHandler);
491
+ }
492
+ }
493
+ add(sample) {
494
+ this.buffer.push(sample);
495
+ if (this.buffer.length >= MAX_BATCH) void this.flush();
496
+ }
497
+ async stop() {
498
+ if (this.timer) clearInterval(this.timer);
499
+ this.timer = null;
500
+ if (this.unloadHandler && typeof window !== "undefined") {
501
+ window.removeEventListener("pagehide", this.unloadHandler);
502
+ }
503
+ this.unloadHandler = null;
504
+ await this.flush();
505
+ }
506
+ /** Send and clear the buffer. `beacon` uses sendBeacon, for page-unload flushes. */
507
+ async flush(beacon = false) {
508
+ if (!this.buffer.length) return;
509
+ const samples = this.buffer.splice(0, MAX_BATCH);
510
+ const body = JSON.stringify({
511
+ sessionId: this.sessionId,
512
+ streamId: this.streamId,
513
+ role: this.role,
514
+ userId: this.userId,
515
+ samples,
516
+ device: describeDevice(),
517
+ network: describeNetwork()
518
+ });
519
+ if (beacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
520
+ const url = `${this.target.url}${this.target.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.target.token)}`;
521
+ try {
522
+ navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
523
+ } catch {
524
+ }
525
+ return;
526
+ }
527
+ try {
528
+ await fetch(this.target.url, {
529
+ method: "POST",
530
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.target.token}` },
531
+ body,
532
+ keepalive: true
533
+ });
534
+ } catch {
535
+ }
536
+ }
537
+ };
538
+ function randomId() {
539
+ const c = typeof crypto === "undefined" ? void 0 : crypto;
540
+ if (c?.randomUUID) return c.randomUUID();
541
+ return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
542
+ }
543
+
426
544
  // src/broadcaster.ts
427
545
  var STATS_INTERVAL_MS = 2e3;
428
546
  var MebiusBroadcaster = class extends TypedEmitter {
429
547
  /** @internal */
430
- constructor(signaling, options) {
548
+ constructor(signaling, options, telemetry = null, userId) {
431
549
  super();
432
550
  this.options = options;
551
+ this.telemetry = telemetry;
552
+ this.userId = userId;
433
553
  this.stream = null;
434
554
  this.facingMode = "user";
435
555
  this.statsTimer = null;
436
556
  this.started = false;
557
+ this.reporter = null;
437
558
  this.transport = createPublishTransport(signaling);
438
559
  }
439
560
  /** Begin broadcasting under the given stream id. */
@@ -442,12 +563,18 @@ var MebiusBroadcaster = class extends TypedEmitter {
442
563
  this.stream = await this.capture();
443
564
  await this.transport.start(streamId, this.stream);
444
565
  this.started = true;
566
+ if (this.telemetry) {
567
+ this.reporter = new QoeReporter(this.telemetry, "pub", streamId, this.userId);
568
+ this.reporter.start();
569
+ }
445
570
  this.startStats();
446
571
  this.emit("started", { streamId });
447
572
  }
448
573
  /** Stop broadcasting and release the camera/microphone. */
449
574
  async stop() {
450
575
  this.stopStats();
576
+ await this.reporter?.stop();
577
+ this.reporter = null;
451
578
  await this.transport.stop();
452
579
  this.stream?.getTracks().forEach((t) => t.stop());
453
580
  this.stream = null;
@@ -503,7 +630,14 @@ var MebiusBroadcaster = class extends TypedEmitter {
503
630
  startStats() {
504
631
  this.statsTimer = setInterval(async () => {
505
632
  const stats = await this.transport.getStats();
506
- if (stats) this.emit("stats", stats);
633
+ if (!stats) return;
634
+ this.emit("stats", stats);
635
+ this.reporter?.add({
636
+ ts: Math.floor(Date.now() / 1e3),
637
+ bitrateKbps: stats.bitrateKbps,
638
+ fps: stats.framesPerSecond,
639
+ rttMs: stats.rttMs
640
+ });
507
641
  }, STATS_INTERVAL_MS);
508
642
  }
509
643
  stopStats() {
@@ -521,12 +655,15 @@ var STATS_INTERVAL_MS2 = 2e3;
521
655
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
522
656
  var MebiusPlayer = class extends TypedEmitter {
523
657
  /** @internal */
524
- constructor(signaling, options = {}, deliveries = []) {
658
+ constructor(signaling, options = {}, deliveries = [], telemetry = null, userId) {
525
659
  super();
660
+ this.telemetry = telemetry;
661
+ this.userId = userId;
526
662
  this.transport = null;
527
663
  this.video = null;
528
664
  this.statsTimer = null;
529
665
  this.playing = false;
666
+ this.reporter = null;
530
667
  this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
531
668
  }
532
669
  /** Start playing `streamId` into the given video element or selector. */
@@ -534,14 +671,21 @@ var MebiusPlayer = class extends TypedEmitter {
534
671
  if (this.playing) return;
535
672
  const video = resolveVideoElement(viewTarget);
536
673
  this.video = video;
674
+ const startedAtMs = Date.now();
537
675
  let lastError = null;
538
676
  for (const candidate of this.candidates) {
539
677
  try {
678
+ resetVideoElement(video);
540
679
  this.attach(candidate);
541
680
  await candidate.start(streamId, video);
542
681
  if (await hasFirstFrame(video)) {
543
682
  this.transport = candidate;
544
683
  this.playing = true;
684
+ if (this.telemetry) {
685
+ this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
686
+ this.reporter.start();
687
+ this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
688
+ }
545
689
  this.startStats();
546
690
  this.emit("playing", { streamId });
547
691
  return;
@@ -558,6 +702,8 @@ var MebiusPlayer = class extends TypedEmitter {
558
702
  /** Stop playback and detach from the video element. */
559
703
  async stop() {
560
704
  this.stopStats();
705
+ await this.reporter?.stop();
706
+ this.reporter = null;
561
707
  await this.transport?.stop();
562
708
  this.transport = null;
563
709
  this.video = null;
@@ -573,6 +719,8 @@ var MebiusPlayer = class extends TypedEmitter {
573
719
  if (this.transport !== transport) return;
574
720
  this.playing = false;
575
721
  this.stopStats();
722
+ void this.reporter?.stop();
723
+ this.reporter = null;
576
724
  this.emit("ended", void 0);
577
725
  });
578
726
  transport.onBuffering(() => {
@@ -583,7 +731,13 @@ var MebiusPlayer = class extends TypedEmitter {
583
731
  startStats() {
584
732
  this.statsTimer = setInterval(async () => {
585
733
  const stats = await this.transport?.getStats();
586
- if (stats) this.emit("stats", stats);
734
+ if (!stats) return;
735
+ this.emit("stats", stats);
736
+ this.reporter?.add({
737
+ ts: Math.floor(Date.now() / 1e3),
738
+ bitrateKbps: stats.bitrateKbps,
739
+ fps: stats.framesPerSecond
740
+ });
587
741
  }, STATS_INTERVAL_MS2);
588
742
  }
589
743
  stopStats() {
@@ -718,10 +872,12 @@ function readToken(token) {
718
872
  // src/client.ts
719
873
  var MebiusClient = class extends TypedEmitter {
720
874
  /** @internal */
721
- constructor(config2, token, deliveries = []) {
875
+ constructor(config2, token, deliveries = [], telemetry = null, userId) {
722
876
  super();
723
877
  this.token = token;
724
878
  this.deliveries = deliveries;
879
+ this.telemetry = telemetry;
880
+ this.userId = userId;
725
881
  this.expiryTimer = null;
726
882
  this.connected = false;
727
883
  this.signaling = new SignalingClient(config2.gateway, token);
@@ -746,12 +902,12 @@ var MebiusClient = class extends TypedEmitter {
746
902
  /** Create a broadcaster bound to this connection. */
747
903
  createBroadcaster(options = {}) {
748
904
  this.assertConnected();
749
- return new MebiusBroadcaster(this.signaling, options);
905
+ return new MebiusBroadcaster(this.signaling, options, this.telemetry, this.userId);
750
906
  }
751
907
  /** Create a player bound to this connection. */
752
908
  createPlayer(options = {}) {
753
909
  this.assertConnected();
754
- return new MebiusPlayer(this.signaling, options, this.deliveries);
910
+ return new MebiusPlayer(this.signaling, options, this.deliveries, this.telemetry, this.userId);
755
911
  }
756
912
  /**
757
913
  * Create a monitor: a player tuned for watching a stream you are interacting
@@ -766,7 +922,7 @@ var MebiusClient = class extends TypedEmitter {
766
922
  */
767
923
  createMonitor() {
768
924
  this.assertConnected();
769
- return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries);
925
+ return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
770
926
  }
771
927
  /** Close the connection and release resources. */
772
928
  disconnect(reason) {
@@ -799,7 +955,8 @@ var Mebius = {
799
955
  throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
800
956
  }
801
957
  if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
802
- const client = new MebiusClient(config, options.token, options.deliveries ?? []);
958
+ const telemetry = options.beaconToken && options.beaconUrl ? { token: options.beaconToken, url: options.beaconUrl } : null;
959
+ const client = new MebiusClient(config, options.token, options.deliveries ?? [], telemetry, options.userId);
803
960
  client.open();
804
961
  return client;
805
962
  },