@mebius-io/web 0.4.9 → 0.5.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.cjs CHANGED
@@ -32,6 +32,7 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  Mebius: () => Mebius,
34
34
  MebiusBroadcaster: () => MebiusBroadcaster,
35
+ MebiusCaptions: () => MebiusCaptions,
35
36
  MebiusClient: () => MebiusClient,
36
37
  MebiusError: () => MebiusError,
37
38
  MebiusPlayer: () => MebiusPlayer,
@@ -439,6 +440,22 @@ var HlsViewTransport = class {
439
440
  framesPerSecond: 0
440
441
  };
441
442
  }
443
+ /**
444
+ * hls.js exposes `playingDate` straight from the segment the element is
445
+ * currently rendering, derived from the playlist's `EXT-X-PROGRAM-DATE-TIME`
446
+ * (MediaMTX writes it). Safari's native player has no such property, but
447
+ * `getStartDate()` (the wall-clock time of the playlist's first segment) plus
448
+ * elapsed `currentTime` is the same clock by construction.
449
+ */
450
+ playheadEpochMs() {
451
+ if (this.hls) return this.hls.playingDate?.getTime() ?? null;
452
+ const video = this.video;
453
+ if (video?.getStartDate) {
454
+ const start = video.getStartDate().getTime();
455
+ if (Number.isFinite(start)) return start + video.currentTime * 1e3;
456
+ }
457
+ return null;
458
+ }
442
459
  };
443
460
 
444
461
  // src/internal/balanced-view-transport.ts
@@ -810,6 +827,82 @@ function normalize(c, fallback) {
810
827
  return c;
811
828
  }
812
829
 
830
+ // src/captions.ts
831
+ var TICK_MS = 100;
832
+ var STALE_MS = 5e3;
833
+ var MebiusCaptions = class extends TypedEmitter {
834
+ /** @internal */
835
+ constructor(signaling, player, opts) {
836
+ super();
837
+ this.signaling = signaling;
838
+ this.player = player;
839
+ this.opts = opts;
840
+ this.es = null;
841
+ this.timer = null;
842
+ this.pending = /* @__PURE__ */ new Map();
843
+ this.shown = /* @__PURE__ */ new Set();
844
+ }
845
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
846
+ start(streamId) {
847
+ if (this.es) return;
848
+ const url = this.signaling.captionsUrl(streamId, this.opts.lang);
849
+ const es = new EventSource(url);
850
+ es.onmessage = (ev) => this.onFrame(ev);
851
+ es.onerror = () => this.emit("error", void 0);
852
+ this.es = es;
853
+ this.timer = setInterval(() => this.tick(), TICK_MS);
854
+ }
855
+ /** Close the connection and drop all buffered segments. */
856
+ stop() {
857
+ this.es?.close();
858
+ this.es = null;
859
+ if (this.timer) clearInterval(this.timer);
860
+ this.timer = null;
861
+ this.pending.clear();
862
+ this.shown.clear();
863
+ }
864
+ onFrame(ev) {
865
+ let frame;
866
+ try {
867
+ frame = JSON.parse(ev.data);
868
+ } catch {
869
+ return;
870
+ }
871
+ if (frame.type !== "caption" || !frame.segmentId) return;
872
+ const prev = this.pending.get(frame.segmentId);
873
+ if (prev && (frame.rev ?? 0) < (prev.rev ?? 0)) return;
874
+ this.pending.set(frame.segmentId, frame);
875
+ }
876
+ tick() {
877
+ const now = this.player.currentEpochMs();
878
+ if (now == null) return;
879
+ for (const [id, frame] of this.pending) {
880
+ const due = frame.epochMs ?? 0;
881
+ if (due > now) continue;
882
+ if (due < now - STALE_MS) {
883
+ this.pending.delete(id);
884
+ if (this.shown.delete(id)) this.emit("cleared", { segmentId: id });
885
+ continue;
886
+ }
887
+ this.shown.add(id);
888
+ this.emit("segment", toSegment(id, frame, this.opts.lang));
889
+ if (frame.state === "final") this.pending.delete(id);
890
+ }
891
+ }
892
+ };
893
+ function toSegment(segmentId, frame, lang) {
894
+ return {
895
+ segmentId,
896
+ rev: frame.rev ?? 0,
897
+ state: frame.state === "final" ? "final" : "interim",
898
+ epochMs: frame.epochMs ?? 0,
899
+ durationMs: frame.durationMs ?? 0,
900
+ text: frame.text ?? "",
901
+ translation: frame.translations?.[lang],
902
+ machineGenerated: true
903
+ };
904
+ }
905
+
813
906
  // src/internal/freeze-clock.ts
814
907
  var FreezeClock = class {
815
908
  constructor(now = Date.now) {
@@ -979,6 +1072,19 @@ var MebiusPlayer = class extends TypedEmitter {
979
1072
  this.video.volume = v;
980
1073
  this.video.muted = v === 0;
981
1074
  }
1075
+ /**
1076
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
1077
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
1078
+ *
1079
+ * This is what {@link MebiusClient.createCaptions} compares against a
1080
+ * segment's `epochMs` to know when it is due. Delegating to the transport
1081
+ * rather than reading the element directly is what keeps this correct across
1082
+ * a route failover: the player may switch from HLS to FLV mid-session, and
1083
+ * the clock source has to follow.
1084
+ */
1085
+ currentEpochMs() {
1086
+ return this.transport?.playheadEpochMs?.() ?? null;
1087
+ }
982
1088
  attach(transport) {
983
1089
  transport.onEnded(() => {
984
1090
  if (this.transport !== transport) return;
@@ -1073,6 +1179,16 @@ var SignalingClient = class {
1073
1179
  scalePlaylistUrl(streamId) {
1074
1180
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
1075
1181
  }
1182
+ /**
1183
+ * Realtime captions SSE URL. Same play token as media — the engine's
1184
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
1185
+ * read its captions with zero extra credential.
1186
+ */
1187
+ captionsUrl(streamId, lang) {
1188
+ return this.withToken(
1189
+ `${this.base()}/live/${encodeURIComponent(streamId)}/captions?lang=${encodeURIComponent(lang)}`
1190
+ );
1191
+ }
1076
1192
  // Maps a neutral session kind to the concrete signaling path segment. This
1077
1193
  // mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
1078
1194
  // the protocol names never appear in any exported type signature.
@@ -1196,6 +1312,22 @@ var MebiusClient = class extends TypedEmitter {
1196
1312
  this.assertConnected();
1197
1313
  return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
1198
1314
  }
1315
+ /**
1316
+ * Subscribe to a stream's realtime captions.
1317
+ *
1318
+ * Reads the same feed a session already produces — it does NOT start the
1319
+ * caption session itself. `captions/start` spends money and requires an API
1320
+ * key, so it belongs to your own backend (see
1321
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
1322
+ * on for a stream. This only ever consumes what that call turned on.
1323
+ *
1324
+ * `player` must be the one showing `streamId`: captions are timed against its
1325
+ * playhead, and a mismatched player would compare against the wrong clock.
1326
+ */
1327
+ createCaptions(player, options) {
1328
+ this.assertConnected();
1329
+ return new MebiusCaptions(this.signaling, player, options);
1330
+ }
1199
1331
  /** Close the connection and release resources. */
1200
1332
  disconnect(reason) {
1201
1333
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
@@ -1241,6 +1373,7 @@ var Mebius = {
1241
1373
  0 && (module.exports = {
1242
1374
  Mebius,
1243
1375
  MebiusBroadcaster,
1376
+ MebiusCaptions,
1244
1377
  MebiusClient,
1245
1378
  MebiusError,
1246
1379
  MebiusPlayer,