@mebius-io/web 0.4.9 → 0.5.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
@@ -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,23 @@ 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
+ * `/api/v1/live/...`, NOT `/live/...`: unlike {@link scalePlaylistUrl}, which
1146
+ * hits the engine's bare `/live/*` media-edge catch-all, captions are mounted
1147
+ * under the versioned control-API group (mebius-stream-engine
1148
+ * internal/api/routes.go) with the play-token middleware, not the proxy.
1149
+ * Copying the media-edge prefix here 401s every request — the catch-all
1150
+ * doesn't recognise the path and never reaches the captions handler at all.
1151
+ */
1152
+ captionsUrl(streamId, lang) {
1153
+ return this.withToken(
1154
+ `${this.base()}/api/v1/live/${encodeURIComponent(streamId)}/captions?lang=${encodeURIComponent(lang)}`
1155
+ );
1156
+ }
1035
1157
  // Maps a neutral session kind to the concrete signaling path segment. This
1036
1158
  // mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
1037
1159
  // the protocol names never appear in any exported type signature.
@@ -1155,6 +1277,22 @@ var MebiusClient = class extends TypedEmitter {
1155
1277
  this.assertConnected();
1156
1278
  return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
1157
1279
  }
1280
+ /**
1281
+ * Subscribe to a stream's realtime captions.
1282
+ *
1283
+ * Reads the same feed a session already produces — it does NOT start the
1284
+ * caption session itself. `captions/start` spends money and requires an API
1285
+ * key, so it belongs to your own backend (see
1286
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
1287
+ * on for a stream. This only ever consumes what that call turned on.
1288
+ *
1289
+ * `player` must be the one showing `streamId`: captions are timed against its
1290
+ * playhead, and a mismatched player would compare against the wrong clock.
1291
+ */
1292
+ createCaptions(player, options) {
1293
+ this.assertConnected();
1294
+ return new MebiusCaptions(this.signaling, player, options);
1295
+ }
1158
1296
  /** Close the connection and release resources. */
1159
1297
  disconnect(reason) {
1160
1298
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
@@ -1199,6 +1337,7 @@ var Mebius = {
1199
1337
  export {
1200
1338
  Mebius,
1201
1339
  MebiusBroadcaster,
1340
+ MebiusCaptions,
1202
1341
  MebiusClient,
1203
1342
  MebiusError,
1204
1343
  MebiusPlayer,