@mebius-io/web 0.4.0 → 0.4.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mebius
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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.4.0/packages/web/standalone/mebius.min.js`
58
+ `https://raw.githubusercontent.com/russimobiledroidx/mebius-web-sdk/v0.4.2/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.4.0`.
63
+ `https://esm.sh/@mebius-io/web@0.4.2`.
64
64
 
65
65
  ## Quick Start
66
66
 
package/dist/index.cjs CHANGED
@@ -129,6 +129,20 @@ var DEFAULT_RTC_CONFIG = {
129
129
  };
130
130
 
131
131
  // src/internal/publish-transport.ts
132
+ function preferH264(pc) {
133
+ const caps = RTCRtpSender.getCapabilities?.("video");
134
+ if (!caps?.codecs) return;
135
+ const h264 = caps.codecs.filter((c) => c.mimeType.toLowerCase() === "video/h264");
136
+ if (h264.length === 0) return;
137
+ const rest = caps.codecs.filter((c) => c.mimeType.toLowerCase() !== "video/h264");
138
+ for (const tr of pc.getTransceivers()) {
139
+ if (tr.sender.track?.kind !== "video") continue;
140
+ try {
141
+ tr.setCodecPreferences?.([...h264, ...rest]);
142
+ } catch {
143
+ }
144
+ }
145
+ }
132
146
  var WhipPublishTransport = class {
133
147
  constructor(signaling) {
134
148
  this.signaling = signaling;
@@ -141,6 +155,7 @@ var WhipPublishTransport = class {
141
155
  for (const track of stream.getTracks()) {
142
156
  pc.addTrack(track, stream);
143
157
  }
158
+ preferH264(pc);
144
159
  const offer = await pc.createOffer();
145
160
  await pc.setLocalDescription(offer);
146
161
  await waitForIceGathering(pc);
@@ -188,6 +203,28 @@ var WhipPublishTransport = class {
188
203
  }
189
204
  };
190
205
 
206
+ // src/internal/autoplay.ts
207
+ function isAutoplayBlocked(e) {
208
+ return typeof e === "object" && e !== null && e.name === "NotAllowedError";
209
+ }
210
+ async function playWithAutoplayFallback(video) {
211
+ video.playsInline = true;
212
+ try {
213
+ await video.play();
214
+ return { mutedByPolicy: false };
215
+ } catch (e) {
216
+ if (!isAutoplayBlocked(e) || video.muted) throw e;
217
+ video.muted = true;
218
+ await video.play();
219
+ return { mutedByPolicy: true };
220
+ }
221
+ }
222
+ function resetVideoElement(video) {
223
+ video.srcObject = null;
224
+ video.removeAttribute("src");
225
+ video.load();
226
+ }
227
+
191
228
  // src/internal/ll-view-transport.ts
192
229
  var WhepViewTransport = class {
193
230
  constructor(signaling) {
@@ -196,6 +233,10 @@ var WhepViewTransport = class {
196
233
  this.resourceUrl = null;
197
234
  this.endedCb = null;
198
235
  this.bufferingCb = null;
236
+ /** Element this route attached a MediaStream to, so stop() can release it. */
237
+ this.video = null;
238
+ /** True when playback only started because the element had to be muted. */
239
+ this.mutedByPolicy = false;
199
240
  }
200
241
  onEnded(cb) {
201
242
  this.endedCb = cb;
@@ -204,6 +245,7 @@ var WhepViewTransport = class {
204
245
  this.bufferingCb = cb;
205
246
  }
206
247
  async start(streamId, video) {
248
+ this.video = video;
207
249
  const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
208
250
  this.pc = pc;
209
251
  const remote = new MediaStream();
@@ -212,7 +254,9 @@ var WhepViewTransport = class {
212
254
  pc.ontrack = (ev) => {
213
255
  remote.addTrack(ev.track);
214
256
  video.srcObject = remote;
215
- void video.play().catch(() => {
257
+ void playWithAutoplayFallback(video).then((o) => {
258
+ this.mutedByPolicy = o.mutedByPolicy;
259
+ }).catch(() => {
216
260
  });
217
261
  };
218
262
  pc.onconnectionstatechange = () => {
@@ -239,6 +283,8 @@ var WhepViewTransport = class {
239
283
  this.resourceUrl = null;
240
284
  this.pc?.close();
241
285
  this.pc = null;
286
+ if (this.video) resetVideoElement(this.video);
287
+ this.video = null;
242
288
  }
243
289
  async getStats() {
244
290
  if (!this.pc) return null;
@@ -276,6 +322,10 @@ var HlsViewTransport = class {
276
322
  this.video = null;
277
323
  this.endedCb = null;
278
324
  this.bufferingCb = null;
325
+ /** Aborts this attempt's element listeners; see start(). */
326
+ this.listeners = null;
327
+ /** True when playback only started because the element had to be muted. */
328
+ this.mutedByPolicy = false;
279
329
  }
280
330
  onEnded(cb) {
281
331
  this.endedCb = cb;
@@ -286,11 +336,13 @@ var HlsViewTransport = class {
286
336
  async start(streamId, video) {
287
337
  this.video = video;
288
338
  const url = this.deliveryPath ? this.signaling.deliveryUrl(this.deliveryPath) : this.signaling.scalePlaylistUrl(streamId);
289
- video.addEventListener("ended", () => this.endedCb?.());
290
- video.addEventListener("waiting", () => this.bufferingCb?.());
339
+ this.listeners = new AbortController();
340
+ const { signal } = this.listeners;
341
+ video.addEventListener("ended", () => this.endedCb?.(), { signal });
342
+ video.addEventListener("waiting", () => this.bufferingCb?.(), { signal });
291
343
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
292
344
  video.src = url;
293
- await video.play().catch(() => void 0);
345
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
294
346
  return;
295
347
  }
296
348
  let mod;
@@ -303,16 +355,18 @@ var HlsViewTransport = class {
303
355
  if (!Hls.isSupported()) {
304
356
  throw mebiusError("CONNECTION_FAILED", "Scale playback is not supported in this browser.");
305
357
  }
306
- const hls = new Hls({ lowLatencyMode: true });
358
+ const hls = new Hls({ maxLiveSyncPlaybackRate: 1.5 });
307
359
  this.hls = hls;
308
360
  hls.on(Hls.Events.ERROR, (_evt, data) => {
309
361
  if (data.fatal) this.bufferingCb?.();
310
362
  });
311
363
  hls.loadSource(url);
312
364
  hls.attachMedia(video);
313
- await video.play().catch(() => void 0);
365
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
314
366
  }
315
367
  async stop() {
368
+ this.listeners?.abort();
369
+ this.listeners = null;
316
370
  this.hls?.destroy();
317
371
  this.hls = null;
318
372
  if (this.video) {
@@ -332,6 +386,40 @@ var HlsViewTransport = class {
332
386
  };
333
387
 
334
388
  // src/internal/balanced-view-transport.ts
389
+ var LIVE_FLV_CONFIG = {
390
+ enableStashBuffer: false,
391
+ stashInitialSize: 128,
392
+ lazyLoad: false,
393
+ autoCleanupSourceBuffer: true,
394
+ autoCleanupMaxBackwardDuration: 30,
395
+ autoCleanupMinBackwardDuration: 10,
396
+ reuseRedirectedURL: true
397
+ };
398
+ var MAX_DRIFT_S = 2;
399
+ var EDGE_MARGIN_S = 0.4;
400
+ var AUDIO_RETRY_MS = 2500;
401
+ function stalledAtZero(video, ms) {
402
+ if (video.currentTime > 0) return Promise.resolve(false);
403
+ return new Promise((resolve) => {
404
+ const done = (stalled) => {
405
+ clearTimeout(timer);
406
+ video.removeEventListener("timeupdate", onTime);
407
+ resolve(stalled);
408
+ };
409
+ const onTime = () => {
410
+ if (video.currentTime > 0) done(false);
411
+ };
412
+ const timer = setTimeout(() => done(true), ms);
413
+ video.addEventListener("timeupdate", onTime);
414
+ });
415
+ }
416
+ function chaseLiveEdge(video) {
417
+ const ranges = video.buffered;
418
+ if (ranges.length === 0) return;
419
+ const edge = ranges.end(ranges.length - 1);
420
+ if (edge - video.currentTime <= MAX_DRIFT_S) return;
421
+ video.currentTime = edge - EDGE_MARGIN_S;
422
+ }
335
423
  var FlvViewTransport = class {
336
424
  constructor(signaling, deliveryPath) {
337
425
  this.signaling = signaling;
@@ -340,6 +428,10 @@ var FlvViewTransport = class {
340
428
  this.video = null;
341
429
  this.endedCb = null;
342
430
  this.bufferingCb = null;
431
+ /** Aborts this attempt's element listeners; see start(). */
432
+ this.listeners = null;
433
+ /** True when playback only started because the element had to be muted. */
434
+ this.mutedByPolicy = false;
343
435
  }
344
436
  onEnded(cb) {
345
437
  this.endedCb = cb;
@@ -350,8 +442,11 @@ var FlvViewTransport = class {
350
442
  async start(_streamId, video) {
351
443
  this.video = video;
352
444
  const url = this.signaling.deliveryUrl(this.deliveryPath);
353
- video.addEventListener("ended", () => this.endedCb?.());
354
- video.addEventListener("waiting", () => this.bufferingCb?.());
445
+ this.listeners = new AbortController();
446
+ const { signal } = this.listeners;
447
+ video.addEventListener("ended", () => this.endedCb?.(), { signal });
448
+ video.addEventListener("waiting", () => this.bufferingCb?.(), { signal });
449
+ video.addEventListener("timeupdate", () => chaseLiveEdge(video), { signal });
355
450
  let mod;
356
451
  try {
357
452
  mod = await import("flv.js");
@@ -362,20 +457,38 @@ var FlvViewTransport = class {
362
457
  if (!flvjs.isSupported()) {
363
458
  throw mebiusError("CONNECTION_FAILED", "Balanced playback is not supported in this browser.");
364
459
  }
365
- const player = flvjs.createPlayer({ type: "flv", url, isLive: true });
460
+ this.attachPlayer(flvjs, video, url, true);
461
+ const firstPlay = playWithAutoplayFallback(video);
462
+ firstPlay.catch(() => void 0);
463
+ if (await stalledAtZero(video, AUDIO_RETRY_MS)) {
464
+ this.teardownPlayer();
465
+ this.attachPlayer(flvjs, video, url, false);
466
+ this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
467
+ return;
468
+ }
469
+ this.mutedByPolicy = (await firstPlay).mutedByPolicy;
470
+ }
471
+ attachPlayer(flvjs, video, url, withAudio) {
472
+ const player = flvjs.createPlayer(
473
+ { type: "flv", url, isLive: true, ...withAudio ? {} : { hasAudio: false } },
474
+ LIVE_FLV_CONFIG
475
+ );
366
476
  this.player = player;
367
477
  player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
368
478
  player.attachMediaElement(video);
369
479
  player.load();
370
- await Promise.resolve(player.play()).catch(() => void 0);
480
+ }
481
+ teardownPlayer() {
482
+ if (!this.player) return;
483
+ this.player.unload();
484
+ this.player.detachMediaElement();
485
+ this.player.destroy();
486
+ this.player = null;
371
487
  }
372
488
  async stop() {
373
- if (this.player) {
374
- this.player.unload();
375
- this.player.detachMediaElement();
376
- this.player.destroy();
377
- this.player = null;
378
- }
489
+ this.listeners?.abort();
490
+ this.listeners = null;
491
+ this.teardownPlayer();
379
492
  if (this.video) {
380
493
  this.video.removeAttribute("src");
381
494
  this.video.load();
@@ -424,7 +537,7 @@ function createViewCandidates(mode, signaling, deliveries = []) {
424
537
  }
425
538
 
426
539
  // src/internal/telemetry.ts
427
- var SDK_VERSION = "web/0.4.0";
540
+ var SDK_VERSION = "web/0.4.2";
428
541
  var FLUSH_INTERVAL_MS = 15e3;
429
542
  var MAX_BATCH = 64;
430
543
  function describeDevice() {
@@ -484,7 +597,7 @@ var QoeReporter = class {
484
597
  if (beacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
485
598
  const url = `${this.target.url}${this.target.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.target.token)}`;
486
599
  try {
487
- navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
600
+ navigator.sendBeacon(url, new Blob([body], { type: "text/plain" }));
488
601
  } catch {
489
602
  }
490
603
  return;
@@ -640,6 +753,7 @@ var MebiusPlayer = class extends TypedEmitter {
640
753
  let lastError = null;
641
754
  for (const candidate of this.candidates) {
642
755
  try {
756
+ resetVideoElement(video);
643
757
  this.attach(candidate);
644
758
  await candidate.start(streamId, video);
645
759
  if (await hasFirstFrame(video)) {