@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.js CHANGED
@@ -398,6 +398,22 @@ var HlsViewTransport = class {
398
398
  framesPerSecond: 0
399
399
  };
400
400
  }
401
+ /**
402
+ * hls.js exposes `playingDate` straight from the segment the element is
403
+ * currently rendering, derived from the playlist's `EXT-X-PROGRAM-DATE-TIME`
404
+ * (MediaMTX writes it). Safari's native player has no such property, but
405
+ * `getStartDate()` (the wall-clock time of the playlist's first segment) plus
406
+ * elapsed `currentTime` is the same clock by construction.
407
+ */
408
+ playheadEpochMs() {
409
+ if (this.hls) return this.hls.playingDate?.getTime() ?? null;
410
+ const video = this.video;
411
+ if (video?.getStartDate) {
412
+ const start = video.getStartDate().getTime();
413
+ if (Number.isFinite(start)) return start + video.currentTime * 1e3;
414
+ }
415
+ return null;
416
+ }
401
417
  };
402
418
 
403
419
  // src/internal/balanced-view-transport.ts
@@ -769,6 +785,82 @@ function normalize(c, fallback) {
769
785
  return c;
770
786
  }
771
787
 
788
+ // src/captions.ts
789
+ var TICK_MS = 100;
790
+ var STALE_MS = 5e3;
791
+ var MebiusCaptions = class extends TypedEmitter {
792
+ /** @internal */
793
+ constructor(signaling, player, opts) {
794
+ super();
795
+ this.signaling = signaling;
796
+ this.player = player;
797
+ this.opts = opts;
798
+ this.es = null;
799
+ this.timer = null;
800
+ this.pending = /* @__PURE__ */ new Map();
801
+ this.shown = /* @__PURE__ */ new Set();
802
+ }
803
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
804
+ start(streamId) {
805
+ if (this.es) return;
806
+ const url = this.signaling.captionsUrl(streamId, this.opts.lang);
807
+ const es = new EventSource(url);
808
+ es.onmessage = (ev) => this.onFrame(ev);
809
+ es.onerror = () => this.emit("error", void 0);
810
+ this.es = es;
811
+ this.timer = setInterval(() => this.tick(), TICK_MS);
812
+ }
813
+ /** Close the connection and drop all buffered segments. */
814
+ stop() {
815
+ this.es?.close();
816
+ this.es = null;
817
+ if (this.timer) clearInterval(this.timer);
818
+ this.timer = null;
819
+ this.pending.clear();
820
+ this.shown.clear();
821
+ }
822
+ onFrame(ev) {
823
+ let frame;
824
+ try {
825
+ frame = JSON.parse(ev.data);
826
+ } catch {
827
+ return;
828
+ }
829
+ if (frame.type !== "caption" || !frame.segmentId) return;
830
+ const prev = this.pending.get(frame.segmentId);
831
+ if (prev && (frame.rev ?? 0) < (prev.rev ?? 0)) return;
832
+ this.pending.set(frame.segmentId, frame);
833
+ }
834
+ tick() {
835
+ const now = this.player.currentEpochMs();
836
+ if (now == null) return;
837
+ for (const [id, frame] of this.pending) {
838
+ const due = frame.epochMs ?? 0;
839
+ if (due > now) continue;
840
+ if (due < now - STALE_MS) {
841
+ this.pending.delete(id);
842
+ if (this.shown.delete(id)) this.emit("cleared", { segmentId: id });
843
+ continue;
844
+ }
845
+ this.shown.add(id);
846
+ this.emit("segment", toSegment(id, frame, this.opts.lang));
847
+ if (frame.state === "final") this.pending.delete(id);
848
+ }
849
+ }
850
+ };
851
+ function toSegment(segmentId, frame, lang) {
852
+ return {
853
+ segmentId,
854
+ rev: frame.rev ?? 0,
855
+ state: frame.state === "final" ? "final" : "interim",
856
+ epochMs: frame.epochMs ?? 0,
857
+ durationMs: frame.durationMs ?? 0,
858
+ text: frame.text ?? "",
859
+ translation: frame.translations?.[lang],
860
+ machineGenerated: true
861
+ };
862
+ }
863
+
772
864
  // src/internal/freeze-clock.ts
773
865
  var FreezeClock = class {
774
866
  constructor(now = Date.now) {
@@ -938,6 +1030,19 @@ var MebiusPlayer = class extends TypedEmitter {
938
1030
  this.video.volume = v;
939
1031
  this.video.muted = v === 0;
940
1032
  }
1033
+ /**
1034
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
1035
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
1036
+ *
1037
+ * This is what {@link MebiusClient.createCaptions} compares against a
1038
+ * segment's `epochMs` to know when it is due. Delegating to the transport
1039
+ * rather than reading the element directly is what keeps this correct across
1040
+ * a route failover: the player may switch from HLS to FLV mid-session, and
1041
+ * the clock source has to follow.
1042
+ */
1043
+ currentEpochMs() {
1044
+ return this.transport?.playheadEpochMs?.() ?? null;
1045
+ }
941
1046
  attach(transport) {
942
1047
  transport.onEnded(() => {
943
1048
  if (this.transport !== transport) return;
@@ -1032,6 +1137,16 @@ var SignalingClient = class {
1032
1137
  scalePlaylistUrl(streamId) {
1033
1138
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
1034
1139
  }
1140
+ /**
1141
+ * Realtime captions SSE URL. Same play token as media — the engine's
1142
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
1143
+ * read its captions with zero extra credential.
1144
+ */
1145
+ captionsUrl(streamId, lang) {
1146
+ return this.withToken(
1147
+ `${this.base()}/live/${encodeURIComponent(streamId)}/captions?lang=${encodeURIComponent(lang)}`
1148
+ );
1149
+ }
1035
1150
  // Maps a neutral session kind to the concrete signaling path segment. This
1036
1151
  // mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
1037
1152
  // the protocol names never appear in any exported type signature.
@@ -1155,6 +1270,22 @@ var MebiusClient = class extends TypedEmitter {
1155
1270
  this.assertConnected();
1156
1271
  return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
1157
1272
  }
1273
+ /**
1274
+ * Subscribe to a stream's realtime captions.
1275
+ *
1276
+ * Reads the same feed a session already produces — it does NOT start the
1277
+ * caption session itself. `captions/start` spends money and requires an API
1278
+ * key, so it belongs to your own backend (see
1279
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
1280
+ * on for a stream. This only ever consumes what that call turned on.
1281
+ *
1282
+ * `player` must be the one showing `streamId`: captions are timed against its
1283
+ * playhead, and a mismatched player would compare against the wrong clock.
1284
+ */
1285
+ createCaptions(player, options) {
1286
+ this.assertConnected();
1287
+ return new MebiusCaptions(this.signaling, player, options);
1288
+ }
1158
1289
  /** Close the connection and release resources. */
1159
1290
  disconnect(reason) {
1160
1291
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
@@ -1199,6 +1330,7 @@ var Mebius = {
1199
1330
  export {
1200
1331
  Mebius,
1201
1332
  MebiusBroadcaster,
1333
+ MebiusCaptions,
1202
1334
  MebiusClient,
1203
1335
  MebiusError,
1204
1336
  MebiusPlayer,