@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/dist/index.js CHANGED
@@ -147,6 +147,28 @@ var WhipPublishTransport = class {
147
147
  }
148
148
  };
149
149
 
150
+ // src/internal/autoplay.ts
151
+ function isAutoplayBlocked(e) {
152
+ return typeof e === "object" && e !== null && e.name === "NotAllowedError";
153
+ }
154
+ async function playWithAutoplayFallback(video) {
155
+ video.playsInline = true;
156
+ try {
157
+ await video.play();
158
+ return { mutedByPolicy: false };
159
+ } catch (e) {
160
+ if (!isAutoplayBlocked(e) || video.muted) throw e;
161
+ video.muted = true;
162
+ await video.play();
163
+ return { mutedByPolicy: true };
164
+ }
165
+ }
166
+ function resetVideoElement(video) {
167
+ video.srcObject = null;
168
+ video.removeAttribute("src");
169
+ video.load();
170
+ }
171
+
150
172
  // src/internal/ll-view-transport.ts
151
173
  var WhepViewTransport = class {
152
174
  constructor(signaling) {
@@ -155,6 +177,10 @@ var WhepViewTransport = class {
155
177
  this.resourceUrl = null;
156
178
  this.endedCb = null;
157
179
  this.bufferingCb = null;
180
+ /** Element this route attached a MediaStream to, so stop() can release it. */
181
+ this.video = null;
182
+ /** True when playback only started because the element had to be muted. */
183
+ this.mutedByPolicy = false;
158
184
  }
159
185
  onEnded(cb) {
160
186
  this.endedCb = cb;
@@ -163,6 +189,7 @@ var WhepViewTransport = class {
163
189
  this.bufferingCb = cb;
164
190
  }
165
191
  async start(streamId, video) {
192
+ this.video = video;
166
193
  const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
167
194
  this.pc = pc;
168
195
  const remote = new MediaStream();
@@ -171,7 +198,9 @@ var WhepViewTransport = class {
171
198
  pc.ontrack = (ev) => {
172
199
  remote.addTrack(ev.track);
173
200
  video.srcObject = remote;
174
- void video.play().catch(() => {
201
+ void playWithAutoplayFallback(video).then((o) => {
202
+ this.mutedByPolicy = o.mutedByPolicy;
203
+ }).catch(() => {
175
204
  });
176
205
  };
177
206
  pc.onconnectionstatechange = () => {
@@ -198,6 +227,8 @@ var WhepViewTransport = class {
198
227
  this.resourceUrl = null;
199
228
  this.pc?.close();
200
229
  this.pc = null;
230
+ if (this.video) resetVideoElement(this.video);
231
+ this.video = null;
201
232
  }
202
233
  async getStats() {
203
234
  if (!this.pc) return null;
@@ -235,6 +266,8 @@ var HlsViewTransport = class {
235
266
  this.video = null;
236
267
  this.endedCb = null;
237
268
  this.bufferingCb = null;
269
+ /** True when playback only started because the element had to be muted. */
270
+ this.mutedByPolicy = false;
238
271
  }
239
272
  onEnded(cb) {
240
273
  this.endedCb = cb;
@@ -249,7 +282,7 @@ var HlsViewTransport = class {
249
282
  video.addEventListener("waiting", () => this.bufferingCb?.());
250
283
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
251
284
  video.src = url;
252
- await video.play().catch(() => void 0);
285
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
253
286
  return;
254
287
  }
255
288
  let mod;
@@ -262,14 +295,14 @@ var HlsViewTransport = class {
262
295
  if (!Hls.isSupported()) {
263
296
  throw mebiusError("CONNECTION_FAILED", "Scale playback is not supported in this browser.");
264
297
  }
265
- const hls = new Hls({ lowLatencyMode: true });
298
+ const hls = new Hls();
266
299
  this.hls = hls;
267
300
  hls.on(Hls.Events.ERROR, (_evt, data) => {
268
301
  if (data.fatal) this.bufferingCb?.();
269
302
  });
270
303
  hls.loadSource(url);
271
304
  hls.attachMedia(video);
272
- await video.play().catch(() => void 0);
305
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
273
306
  }
274
307
  async stop() {
275
308
  this.hls?.destroy();
@@ -299,6 +332,8 @@ var FlvViewTransport = class {
299
332
  this.video = null;
300
333
  this.endedCb = null;
301
334
  this.bufferingCb = null;
335
+ /** True when playback only started because the element had to be muted. */
336
+ this.mutedByPolicy = false;
302
337
  }
303
338
  onEnded(cb) {
304
339
  this.endedCb = cb;
@@ -326,7 +361,7 @@ var FlvViewTransport = class {
326
361
  player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
327
362
  player.attachMediaElement(video);
328
363
  player.load();
329
- await Promise.resolve(player.play()).catch(() => void 0);
364
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
330
365
  }
331
366
  async stop() {
332
367
  if (this.player) {
@@ -382,17 +417,103 @@ function createViewCandidates(mode, signaling, deliveries = []) {
382
417
  }
383
418
  }
384
419
 
420
+ // src/internal/telemetry.ts
421
+ var SDK_VERSION = "web/0.4.1";
422
+ var FLUSH_INTERVAL_MS = 15e3;
423
+ var MAX_BATCH = 64;
424
+ function describeDevice() {
425
+ const nav = typeof navigator === "undefined" ? void 0 : navigator;
426
+ const uaData = nav?.userAgentData;
427
+ return { os: uaData?.platform || nav?.platform || void 0, sdk: SDK_VERSION };
428
+ }
429
+ function describeNetwork() {
430
+ const conn = typeof navigator === "undefined" ? void 0 : navigator.connection;
431
+ return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
432
+ }
433
+ var QoeReporter = class {
434
+ constructor(target, role, streamId, userId) {
435
+ this.target = target;
436
+ this.role = role;
437
+ this.streamId = streamId;
438
+ this.userId = userId;
439
+ this.sessionId = randomId();
440
+ this.buffer = [];
441
+ this.timer = null;
442
+ this.unloadHandler = null;
443
+ }
444
+ start() {
445
+ if (this.timer) return;
446
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
447
+ if (typeof window !== "undefined") {
448
+ this.unloadHandler = () => void this.flush(true);
449
+ window.addEventListener("pagehide", this.unloadHandler);
450
+ }
451
+ }
452
+ add(sample) {
453
+ this.buffer.push(sample);
454
+ if (this.buffer.length >= MAX_BATCH) void this.flush();
455
+ }
456
+ async stop() {
457
+ if (this.timer) clearInterval(this.timer);
458
+ this.timer = null;
459
+ if (this.unloadHandler && typeof window !== "undefined") {
460
+ window.removeEventListener("pagehide", this.unloadHandler);
461
+ }
462
+ this.unloadHandler = null;
463
+ await this.flush();
464
+ }
465
+ /** Send and clear the buffer. `beacon` uses sendBeacon, for page-unload flushes. */
466
+ async flush(beacon = false) {
467
+ if (!this.buffer.length) return;
468
+ const samples = this.buffer.splice(0, MAX_BATCH);
469
+ const body = JSON.stringify({
470
+ sessionId: this.sessionId,
471
+ streamId: this.streamId,
472
+ role: this.role,
473
+ userId: this.userId,
474
+ samples,
475
+ device: describeDevice(),
476
+ network: describeNetwork()
477
+ });
478
+ if (beacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
479
+ const url = `${this.target.url}${this.target.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.target.token)}`;
480
+ try {
481
+ navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
482
+ } catch {
483
+ }
484
+ return;
485
+ }
486
+ try {
487
+ await fetch(this.target.url, {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.target.token}` },
490
+ body,
491
+ keepalive: true
492
+ });
493
+ } catch {
494
+ }
495
+ }
496
+ };
497
+ function randomId() {
498
+ const c = typeof crypto === "undefined" ? void 0 : crypto;
499
+ if (c?.randomUUID) return c.randomUUID();
500
+ return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
501
+ }
502
+
385
503
  // src/broadcaster.ts
386
504
  var STATS_INTERVAL_MS = 2e3;
387
505
  var MebiusBroadcaster = class extends TypedEmitter {
388
506
  /** @internal */
389
- constructor(signaling, options) {
507
+ constructor(signaling, options, telemetry = null, userId) {
390
508
  super();
391
509
  this.options = options;
510
+ this.telemetry = telemetry;
511
+ this.userId = userId;
392
512
  this.stream = null;
393
513
  this.facingMode = "user";
394
514
  this.statsTimer = null;
395
515
  this.started = false;
516
+ this.reporter = null;
396
517
  this.transport = createPublishTransport(signaling);
397
518
  }
398
519
  /** Begin broadcasting under the given stream id. */
@@ -401,12 +522,18 @@ var MebiusBroadcaster = class extends TypedEmitter {
401
522
  this.stream = await this.capture();
402
523
  await this.transport.start(streamId, this.stream);
403
524
  this.started = true;
525
+ if (this.telemetry) {
526
+ this.reporter = new QoeReporter(this.telemetry, "pub", streamId, this.userId);
527
+ this.reporter.start();
528
+ }
404
529
  this.startStats();
405
530
  this.emit("started", { streamId });
406
531
  }
407
532
  /** Stop broadcasting and release the camera/microphone. */
408
533
  async stop() {
409
534
  this.stopStats();
535
+ await this.reporter?.stop();
536
+ this.reporter = null;
410
537
  await this.transport.stop();
411
538
  this.stream?.getTracks().forEach((t) => t.stop());
412
539
  this.stream = null;
@@ -462,7 +589,14 @@ var MebiusBroadcaster = class extends TypedEmitter {
462
589
  startStats() {
463
590
  this.statsTimer = setInterval(async () => {
464
591
  const stats = await this.transport.getStats();
465
- if (stats) this.emit("stats", stats);
592
+ if (!stats) return;
593
+ this.emit("stats", stats);
594
+ this.reporter?.add({
595
+ ts: Math.floor(Date.now() / 1e3),
596
+ bitrateKbps: stats.bitrateKbps,
597
+ fps: stats.framesPerSecond,
598
+ rttMs: stats.rttMs
599
+ });
466
600
  }, STATS_INTERVAL_MS);
467
601
  }
468
602
  stopStats() {
@@ -480,12 +614,15 @@ var STATS_INTERVAL_MS2 = 2e3;
480
614
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
481
615
  var MebiusPlayer = class extends TypedEmitter {
482
616
  /** @internal */
483
- constructor(signaling, options = {}, deliveries = []) {
617
+ constructor(signaling, options = {}, deliveries = [], telemetry = null, userId) {
484
618
  super();
619
+ this.telemetry = telemetry;
620
+ this.userId = userId;
485
621
  this.transport = null;
486
622
  this.video = null;
487
623
  this.statsTimer = null;
488
624
  this.playing = false;
625
+ this.reporter = null;
489
626
  this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
490
627
  }
491
628
  /** Start playing `streamId` into the given video element or selector. */
@@ -493,14 +630,21 @@ var MebiusPlayer = class extends TypedEmitter {
493
630
  if (this.playing) return;
494
631
  const video = resolveVideoElement(viewTarget);
495
632
  this.video = video;
633
+ const startedAtMs = Date.now();
496
634
  let lastError = null;
497
635
  for (const candidate of this.candidates) {
498
636
  try {
637
+ resetVideoElement(video);
499
638
  this.attach(candidate);
500
639
  await candidate.start(streamId, video);
501
640
  if (await hasFirstFrame(video)) {
502
641
  this.transport = candidate;
503
642
  this.playing = true;
643
+ if (this.telemetry) {
644
+ this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
645
+ this.reporter.start();
646
+ this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
647
+ }
504
648
  this.startStats();
505
649
  this.emit("playing", { streamId });
506
650
  return;
@@ -517,6 +661,8 @@ var MebiusPlayer = class extends TypedEmitter {
517
661
  /** Stop playback and detach from the video element. */
518
662
  async stop() {
519
663
  this.stopStats();
664
+ await this.reporter?.stop();
665
+ this.reporter = null;
520
666
  await this.transport?.stop();
521
667
  this.transport = null;
522
668
  this.video = null;
@@ -532,6 +678,8 @@ var MebiusPlayer = class extends TypedEmitter {
532
678
  if (this.transport !== transport) return;
533
679
  this.playing = false;
534
680
  this.stopStats();
681
+ void this.reporter?.stop();
682
+ this.reporter = null;
535
683
  this.emit("ended", void 0);
536
684
  });
537
685
  transport.onBuffering(() => {
@@ -542,7 +690,13 @@ var MebiusPlayer = class extends TypedEmitter {
542
690
  startStats() {
543
691
  this.statsTimer = setInterval(async () => {
544
692
  const stats = await this.transport?.getStats();
545
- if (stats) this.emit("stats", stats);
693
+ if (!stats) return;
694
+ this.emit("stats", stats);
695
+ this.reporter?.add({
696
+ ts: Math.floor(Date.now() / 1e3),
697
+ bitrateKbps: stats.bitrateKbps,
698
+ fps: stats.framesPerSecond
699
+ });
546
700
  }, STATS_INTERVAL_MS2);
547
701
  }
548
702
  stopStats() {
@@ -677,10 +831,12 @@ function readToken(token) {
677
831
  // src/client.ts
678
832
  var MebiusClient = class extends TypedEmitter {
679
833
  /** @internal */
680
- constructor(config2, token, deliveries = []) {
834
+ constructor(config2, token, deliveries = [], telemetry = null, userId) {
681
835
  super();
682
836
  this.token = token;
683
837
  this.deliveries = deliveries;
838
+ this.telemetry = telemetry;
839
+ this.userId = userId;
684
840
  this.expiryTimer = null;
685
841
  this.connected = false;
686
842
  this.signaling = new SignalingClient(config2.gateway, token);
@@ -705,12 +861,12 @@ var MebiusClient = class extends TypedEmitter {
705
861
  /** Create a broadcaster bound to this connection. */
706
862
  createBroadcaster(options = {}) {
707
863
  this.assertConnected();
708
- return new MebiusBroadcaster(this.signaling, options);
864
+ return new MebiusBroadcaster(this.signaling, options, this.telemetry, this.userId);
709
865
  }
710
866
  /** Create a player bound to this connection. */
711
867
  createPlayer(options = {}) {
712
868
  this.assertConnected();
713
- return new MebiusPlayer(this.signaling, options, this.deliveries);
869
+ return new MebiusPlayer(this.signaling, options, this.deliveries, this.telemetry, this.userId);
714
870
  }
715
871
  /**
716
872
  * Create a monitor: a player tuned for watching a stream you are interacting
@@ -725,7 +881,7 @@ var MebiusClient = class extends TypedEmitter {
725
881
  */
726
882
  createMonitor() {
727
883
  this.assertConnected();
728
- return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries);
884
+ return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
729
885
  }
730
886
  /** Close the connection and release resources. */
731
887
  disconnect(reason) {
@@ -758,7 +914,8 @@ var Mebius = {
758
914
  throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
759
915
  }
760
916
  if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
761
- const client = new MebiusClient(config, options.token, options.deliveries ?? []);
917
+ const telemetry = options.beaconToken && options.beaconUrl ? { token: options.beaconToken, url: options.beaconUrl } : null;
918
+ const client = new MebiusClient(config, options.token, options.deliveries ?? [], telemetry, options.userId);
762
919
  client.open();
763
920
  return client;
764
921
  },