@mebius-io/web 0.1.3 → 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 +54 -12
- package/dist/index.cjs +172 -88
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -13
- package/dist/index.d.ts +59 -13
- package/dist/index.global.js +9700 -97
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +172 -88
- package/dist/index.js.map +1 -1
- package/package.json +2 -9
package/dist/index.js
CHANGED
|
@@ -220,11 +220,18 @@ var WhepViewTransport = class {
|
|
|
220
220
|
}
|
|
221
221
|
};
|
|
222
222
|
|
|
223
|
-
// src/internal/
|
|
224
|
-
var
|
|
225
|
-
|
|
223
|
+
// src/internal/scale-view-transport.ts
|
|
224
|
+
var HlsViewTransport = class {
|
|
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;
|
|
227
|
-
this.
|
|
233
|
+
this.deliveryPath = deliveryPath;
|
|
234
|
+
this.hls = null;
|
|
228
235
|
this.video = null;
|
|
229
236
|
this.endedCb = null;
|
|
230
237
|
this.bufferingCb = null;
|
|
@@ -237,37 +244,36 @@ var FlvViewTransport = class {
|
|
|
237
244
|
}
|
|
238
245
|
async start(streamId, video) {
|
|
239
246
|
this.video = video;
|
|
240
|
-
const url = this.signaling.
|
|
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?.());
|
|
250
|
+
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
251
|
+
video.src = url;
|
|
252
|
+
await video.play().catch(() => void 0);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
243
255
|
let mod;
|
|
244
256
|
try {
|
|
245
|
-
|
|
246
|
-
mod = await import(
|
|
247
|
-
/* @vite-ignore */
|
|
248
|
-
spec
|
|
249
|
-
);
|
|
257
|
+
mod = await import("hls.js");
|
|
250
258
|
} catch (cause) {
|
|
251
|
-
throw mebiusError("CONNECTION_FAILED", "
|
|
259
|
+
throw mebiusError("CONNECTION_FAILED", "Scale playback support failed to load.", cause);
|
|
252
260
|
}
|
|
253
|
-
const
|
|
254
|
-
if (!
|
|
255
|
-
throw mebiusError("CONNECTION_FAILED", "
|
|
261
|
+
const Hls = mod.default;
|
|
262
|
+
if (!Hls.isSupported()) {
|
|
263
|
+
throw mebiusError("CONNECTION_FAILED", "Scale playback is not supported in this browser.");
|
|
256
264
|
}
|
|
257
|
-
const
|
|
258
|
-
this.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const hls = new Hls({ lowLatencyMode: true });
|
|
266
|
+
this.hls = hls;
|
|
267
|
+
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
268
|
+
if (data.fatal) this.bufferingCb?.();
|
|
269
|
+
});
|
|
270
|
+
hls.loadSource(url);
|
|
271
|
+
hls.attachMedia(video);
|
|
272
|
+
await video.play().catch(() => void 0);
|
|
263
273
|
}
|
|
264
274
|
async stop() {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.player.detachMediaElement();
|
|
268
|
-
this.player.destroy();
|
|
269
|
-
this.player = null;
|
|
270
|
-
}
|
|
275
|
+
this.hls?.destroy();
|
|
276
|
+
this.hls = null;
|
|
271
277
|
if (this.video) {
|
|
272
278
|
this.video.removeAttribute("src");
|
|
273
279
|
this.video.load();
|
|
@@ -276,19 +282,20 @@ var FlvViewTransport = class {
|
|
|
276
282
|
}
|
|
277
283
|
async getStats() {
|
|
278
284
|
if (!this.video) return null;
|
|
285
|
+
const level = this.hls?.levels?.[this.hls.currentLevel];
|
|
279
286
|
return {
|
|
280
|
-
bitrateKbps: 0,
|
|
281
|
-
framesPerSecond: 0
|
|
282
|
-
latencyMs: void 0
|
|
287
|
+
bitrateKbps: level ? Math.round(level.bitrate / 1e3) : 0,
|
|
288
|
+
framesPerSecond: 0
|
|
283
289
|
};
|
|
284
290
|
}
|
|
285
291
|
};
|
|
286
292
|
|
|
287
|
-
// src/internal/
|
|
288
|
-
var
|
|
289
|
-
constructor(signaling) {
|
|
293
|
+
// src/internal/balanced-view-transport.ts
|
|
294
|
+
var FlvViewTransport = class {
|
|
295
|
+
constructor(signaling, deliveryPath) {
|
|
290
296
|
this.signaling = signaling;
|
|
291
|
-
this.
|
|
297
|
+
this.deliveryPath = deliveryPath;
|
|
298
|
+
this.player = null;
|
|
292
299
|
this.video = null;
|
|
293
300
|
this.endedCb = null;
|
|
294
301
|
this.bufferingCb = null;
|
|
@@ -299,38 +306,35 @@ var HlsViewTransport = class {
|
|
|
299
306
|
onBuffering(cb) {
|
|
300
307
|
this.bufferingCb = cb;
|
|
301
308
|
}
|
|
302
|
-
async start(
|
|
309
|
+
async start(_streamId, video) {
|
|
303
310
|
this.video = video;
|
|
304
|
-
const url = this.signaling.
|
|
311
|
+
const url = this.signaling.deliveryUrl(this.deliveryPath);
|
|
305
312
|
video.addEventListener("ended", () => this.endedCb?.());
|
|
306
313
|
video.addEventListener("waiting", () => this.bufferingCb?.());
|
|
307
|
-
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
308
|
-
video.src = url;
|
|
309
|
-
await video.play().catch(() => void 0);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
314
|
let mod;
|
|
313
315
|
try {
|
|
314
|
-
mod = await import("
|
|
316
|
+
mod = await import("flv.js");
|
|
315
317
|
} catch (cause) {
|
|
316
|
-
throw mebiusError("CONNECTION_FAILED", "
|
|
318
|
+
throw mebiusError("CONNECTION_FAILED", "Balanced playback support failed to load.", cause);
|
|
317
319
|
}
|
|
318
|
-
const
|
|
319
|
-
if (!
|
|
320
|
-
throw mebiusError("CONNECTION_FAILED", "
|
|
320
|
+
const flvjs = mod.default;
|
|
321
|
+
if (!flvjs.isSupported()) {
|
|
322
|
+
throw mebiusError("CONNECTION_FAILED", "Balanced playback is not supported in this browser.");
|
|
321
323
|
}
|
|
322
|
-
const
|
|
323
|
-
this.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
hls.attachMedia(video);
|
|
329
|
-
await video.play().catch(() => void 0);
|
|
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
330
|
}
|
|
331
331
|
async stop() {
|
|
332
|
-
this.
|
|
333
|
-
|
|
332
|
+
if (this.player) {
|
|
333
|
+
this.player.unload();
|
|
334
|
+
this.player.detachMediaElement();
|
|
335
|
+
this.player.destroy();
|
|
336
|
+
this.player = null;
|
|
337
|
+
}
|
|
334
338
|
if (this.video) {
|
|
335
339
|
this.video.removeAttribute("src");
|
|
336
340
|
this.video.load();
|
|
@@ -339,10 +343,10 @@ var HlsViewTransport = class {
|
|
|
339
343
|
}
|
|
340
344
|
async getStats() {
|
|
341
345
|
if (!this.video) return null;
|
|
342
|
-
const level = this.hls?.levels?.[this.hls.currentLevel];
|
|
343
346
|
return {
|
|
344
|
-
bitrateKbps:
|
|
345
|
-
framesPerSecond: 0
|
|
347
|
+
bitrateKbps: 0,
|
|
348
|
+
framesPerSecond: 0,
|
|
349
|
+
latencyMs: void 0
|
|
346
350
|
};
|
|
347
351
|
}
|
|
348
352
|
};
|
|
@@ -351,14 +355,30 @@ var HlsViewTransport = class {
|
|
|
351
355
|
function createPublishTransport(signaling) {
|
|
352
356
|
return new WhipPublishTransport(signaling);
|
|
353
357
|
}
|
|
354
|
-
|
|
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];
|
|
355
373
|
switch (mode) {
|
|
356
374
|
case "low-latency":
|
|
357
|
-
return new WhepViewTransport(signaling);
|
|
375
|
+
return [new WhepViewTransport(signaling), ...fromGateway(allKinds), originFallback];
|
|
358
376
|
case "balanced":
|
|
359
|
-
return
|
|
377
|
+
return [...fromGateway(allKinds), originFallback];
|
|
360
378
|
case "scale":
|
|
361
|
-
return
|
|
379
|
+
return [...fromGateway([KIND_WIDE, KIND_LOCAL]), originFallback];
|
|
380
|
+
case "auto":
|
|
381
|
+
return [...fromGateway(allKinds), originFallback];
|
|
362
382
|
}
|
|
363
383
|
}
|
|
364
384
|
|
|
@@ -457,34 +477,48 @@ function normalize(c, fallback) {
|
|
|
457
477
|
|
|
458
478
|
// src/player.ts
|
|
459
479
|
var STATS_INTERVAL_MS2 = 2e3;
|
|
480
|
+
var FIRST_FRAME_TIMEOUT_MS = 8e3;
|
|
460
481
|
var MebiusPlayer = class extends TypedEmitter {
|
|
461
482
|
/** @internal */
|
|
462
|
-
constructor(signaling, options) {
|
|
483
|
+
constructor(signaling, options = {}, deliveries = []) {
|
|
463
484
|
super();
|
|
485
|
+
this.transport = null;
|
|
464
486
|
this.video = null;
|
|
465
487
|
this.statsTimer = null;
|
|
466
488
|
this.playing = false;
|
|
467
|
-
this.
|
|
468
|
-
this.transport.onEnded(() => {
|
|
469
|
-
this.playing = false;
|
|
470
|
-
this.stopStats();
|
|
471
|
-
this.emit("ended", void 0);
|
|
472
|
-
});
|
|
473
|
-
this.transport.onBuffering(() => this.emit("buffering", void 0));
|
|
489
|
+
this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
|
|
474
490
|
}
|
|
475
491
|
/** Start playing `streamId` into the given video element or selector. */
|
|
476
492
|
async play(streamId, viewTarget) {
|
|
477
493
|
if (this.playing) return;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
this.
|
|
482
|
-
|
|
494
|
+
const video = resolveVideoElement(viewTarget);
|
|
495
|
+
this.video = video;
|
|
496
|
+
let lastError = null;
|
|
497
|
+
for (const candidate of this.candidates) {
|
|
498
|
+
try {
|
|
499
|
+
this.attach(candidate);
|
|
500
|
+
await candidate.start(streamId, video);
|
|
501
|
+
if (await hasFirstFrame(video)) {
|
|
502
|
+
this.transport = candidate;
|
|
503
|
+
this.playing = true;
|
|
504
|
+
this.startStats();
|
|
505
|
+
this.emit("playing", { streamId });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
lastError = mebiusError("CONNECTION_FAILED", "A Mebius route delivered no video.");
|
|
509
|
+
} catch (cause) {
|
|
510
|
+
lastError = cause;
|
|
511
|
+
}
|
|
512
|
+
await candidate.stop().catch(() => void 0);
|
|
513
|
+
}
|
|
514
|
+
this.video = null;
|
|
515
|
+
throw lastError ?? mebiusError("CONNECTION_FAILED", "No Mebius route could play this stream.");
|
|
483
516
|
}
|
|
484
517
|
/** Stop playback and detach from the video element. */
|
|
485
518
|
async stop() {
|
|
486
519
|
this.stopStats();
|
|
487
|
-
await this.transport
|
|
520
|
+
await this.transport?.stop();
|
|
521
|
+
this.transport = null;
|
|
488
522
|
this.video = null;
|
|
489
523
|
this.playing = false;
|
|
490
524
|
}
|
|
@@ -493,9 +527,21 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
493
527
|
const v = Math.min(1, Math.max(0, volume));
|
|
494
528
|
if (this.video) this.video.volume = v;
|
|
495
529
|
}
|
|
530
|
+
attach(transport) {
|
|
531
|
+
transport.onEnded(() => {
|
|
532
|
+
if (this.transport !== transport) return;
|
|
533
|
+
this.playing = false;
|
|
534
|
+
this.stopStats();
|
|
535
|
+
this.emit("ended", void 0);
|
|
536
|
+
});
|
|
537
|
+
transport.onBuffering(() => {
|
|
538
|
+
if (this.transport !== transport) return;
|
|
539
|
+
this.emit("buffering", void 0);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
496
542
|
startStats() {
|
|
497
543
|
this.statsTimer = setInterval(async () => {
|
|
498
|
-
const stats = await this.transport
|
|
544
|
+
const stats = await this.transport?.getStats();
|
|
499
545
|
if (stats) this.emit("stats", stats);
|
|
500
546
|
}, STATS_INTERVAL_MS2);
|
|
501
547
|
}
|
|
@@ -504,6 +550,21 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
504
550
|
this.statsTimer = null;
|
|
505
551
|
}
|
|
506
552
|
};
|
|
553
|
+
function hasFirstFrame(video) {
|
|
554
|
+
if (video.currentTime > 0 && !video.paused) return Promise.resolve(true);
|
|
555
|
+
return new Promise((resolve) => {
|
|
556
|
+
const done = (ok) => {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
video.removeEventListener("timeupdate", onTime);
|
|
559
|
+
resolve(ok);
|
|
560
|
+
};
|
|
561
|
+
const onTime = () => {
|
|
562
|
+
if (video.currentTime > 0) done(true);
|
|
563
|
+
};
|
|
564
|
+
const timer = setTimeout(() => done(false), FIRST_FRAME_TIMEOUT_MS);
|
|
565
|
+
video.addEventListener("timeupdate", onTime);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
507
568
|
|
|
508
569
|
// src/internal/signaling.ts
|
|
509
570
|
var SignalingClient = class {
|
|
@@ -527,17 +588,24 @@ var SignalingClient = class {
|
|
|
527
588
|
// Build the playlist URL used by scale-mode playback (HLS path, hidden). The
|
|
528
589
|
// engine serves the playlist under /live/{id}/index.m3u8 and requires the
|
|
529
590
|
// token in the query; segment URIs in the playlist inherit it automatically.
|
|
591
|
+
/**
|
|
592
|
+
* Absolute, tokenized URL for a gateway-relative delivery path handed to us
|
|
593
|
+
* by the gateway (`deliveries[].path`). The gateway decides which paths exist
|
|
594
|
+
* and in what order; the SDK only resolves them against its own base and
|
|
595
|
+
* attaches the access token. Anything that is not a plain gateway-relative
|
|
596
|
+
* path is rejected rather than fetched: an absolute URL there would send the
|
|
597
|
+
* token to a host we did not choose.
|
|
598
|
+
*/
|
|
599
|
+
deliveryUrl(path) {
|
|
600
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("://")) {
|
|
601
|
+
throw mebiusError("CONNECTION_FAILED", "The gateway returned an unusable delivery path.");
|
|
602
|
+
}
|
|
603
|
+
return this.withToken(`${this.base()}${path}`);
|
|
604
|
+
}
|
|
530
605
|
/** Playlist URL for scale-mode playback. */
|
|
531
606
|
scalePlaylistUrl(streamId) {
|
|
532
607
|
return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
|
|
533
608
|
}
|
|
534
|
-
// Build the HTTP-FLV pull URL used by balanced-mode playback. Served by the
|
|
535
|
-
// CDN (CDN_PULL_FORMAT `.flv`) or an SRS/nginx-rtmp edge in front of the
|
|
536
|
-
// engine — MediaMTX itself does not vend HTTP-FLV. Hidden from the public API.
|
|
537
|
-
/** Pull URL for balanced-mode playback. */
|
|
538
|
-
balancedStreamUrl(streamId) {
|
|
539
|
-
return this.withToken(`${this.base()}/flv/${encodeURIComponent(streamId)}.flv`);
|
|
540
|
-
}
|
|
541
609
|
// Maps a neutral session kind to the concrete signaling path segment. This
|
|
542
610
|
// mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
|
|
543
611
|
// the protocol names never appear in any exported type signature.
|
|
@@ -609,9 +677,10 @@ function readToken(token) {
|
|
|
609
677
|
// src/client.ts
|
|
610
678
|
var MebiusClient = class extends TypedEmitter {
|
|
611
679
|
/** @internal */
|
|
612
|
-
constructor(config2, token) {
|
|
680
|
+
constructor(config2, token, deliveries = []) {
|
|
613
681
|
super();
|
|
614
682
|
this.token = token;
|
|
683
|
+
this.deliveries = deliveries;
|
|
615
684
|
this.expiryTimer = null;
|
|
616
685
|
this.connected = false;
|
|
617
686
|
this.signaling = new SignalingClient(config2.gateway, token);
|
|
@@ -639,9 +708,24 @@ var MebiusClient = class extends TypedEmitter {
|
|
|
639
708
|
return new MebiusBroadcaster(this.signaling, options);
|
|
640
709
|
}
|
|
641
710
|
/** Create a player bound to this connection. */
|
|
642
|
-
createPlayer(options) {
|
|
711
|
+
createPlayer(options = {}) {
|
|
712
|
+
this.assertConnected();
|
|
713
|
+
return new MebiusPlayer(this.signaling, options, this.deliveries);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Create a monitor: a player tuned for watching a stream you are interacting
|
|
717
|
+
* WITH rather than merely watching — the other side of a co-broadcast, where a
|
|
718
|
+
* second or two of delay makes the interaction feel broken.
|
|
719
|
+
*
|
|
720
|
+
* It is a player with the delay budget spent differently, not a different API:
|
|
721
|
+
* it starts on the real-time route and falls back on its own if that route
|
|
722
|
+
* delivers no frames. Apps used to hand-roll this (open a real-time view, run a
|
|
723
|
+
* timer, swap players when it stayed black); getting the fallback wrong showed a
|
|
724
|
+
* black frame to a live audience, so it belongs here rather than in every app.
|
|
725
|
+
*/
|
|
726
|
+
createMonitor() {
|
|
643
727
|
this.assertConnected();
|
|
644
|
-
return new MebiusPlayer(this.signaling,
|
|
728
|
+
return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries);
|
|
645
729
|
}
|
|
646
730
|
/** Close the connection and release resources. */
|
|
647
731
|
disconnect(reason) {
|
|
@@ -674,7 +758,7 @@ var Mebius = {
|
|
|
674
758
|
throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
|
|
675
759
|
}
|
|
676
760
|
if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
|
|
677
|
-
const client = new MebiusClient(config, options.token);
|
|
761
|
+
const client = new MebiusClient(config, options.token, options.deliveries ?? []);
|
|
678
762
|
client.open();
|
|
679
763
|
return client;
|
|
680
764
|
},
|