@mebius-io/web 0.4.8 → 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.d.cts CHANGED
@@ -123,6 +123,37 @@ interface PlaybackStats {
123
123
  /** Estimated end-to-end latency in milliseconds, if known. */
124
124
  latencyMs?: number;
125
125
  }
126
+ /** Options for {@link MebiusClient.createCaptions}. */
127
+ interface CaptionsOptions {
128
+ /**
129
+ * Which translation to read from each segment, e.g. `"id"`. Must match a
130
+ * `targetLangs` entry your backend passed to `captions/start` — the engine
131
+ * only ever sends the translations that session produced.
132
+ */
133
+ lang: string;
134
+ }
135
+ /**
136
+ * One caption segment, timed against a wall clock so it can be compared to
137
+ * {@link MebiusPlayer.currentEpochMs}.
138
+ */
139
+ interface CaptionSegment {
140
+ /** Stable id for this segment. A later revision replaces it in place. */
141
+ segmentId: string;
142
+ /** Revision counter. Only the highest-`rev` copy of a `segmentId` is kept. */
143
+ rev: number;
144
+ /** `"interim"` or `"final"`. Interim only arrives if the session enabled it. */
145
+ state: "interim" | "final";
146
+ /** Unix ms the audio was actually spoken. Compare against the playhead. */
147
+ epochMs: number;
148
+ /** How long the segment should stay on screen once due, in ms. */
149
+ durationMs: number;
150
+ /** Original transcript, in the source language. */
151
+ text: string;
152
+ /** The requested {@link CaptionsOptions.lang} translation, if produced yet. */
153
+ translation?: string;
154
+ /** Always `true`. Render a machine-generated indicator — never as a direct quote. */
155
+ machineGenerated: true;
156
+ }
126
157
  /** Canonical Mebius error codes surfaced to your app. */
127
158
  type MebiusErrorCode = "TOKEN_EXPIRED" | "PERMISSION_DENIED" | "CONNECTION_FAILED" | "NOT_CONNECTED" | "STREAM_NOT_FOUND" | "UNKNOWN";
128
159
 
@@ -167,6 +198,17 @@ type PlayerEventMap = {
167
198
  ended: void;
168
199
  stats: PlaybackStats;
169
200
  };
201
+ /** Event payloads emitted by {@link MebiusCaptions}. */
202
+ type CaptionsEventMap = {
203
+ /** A segment became due (its `epochMs` reached the playhead). Render it. */
204
+ segment: CaptionSegment;
205
+ /** A previously-shown segment aged out (playhead passed its window). Clear it. */
206
+ cleared: {
207
+ segmentId: string;
208
+ };
209
+ /** The SSE connection dropped. `EventSource` reconnects on its own. */
210
+ error: void;
211
+ };
170
212
  type Listener<T> = (payload: T) => void;
171
213
  /**
172
214
  * A tiny strongly-typed event emitter. `EventMap` maps each event name to its
@@ -216,6 +258,12 @@ declare class SignalingClient {
216
258
  deliveryUrl(path: string): string;
217
259
  /** Playlist URL for scale-mode playback. */
218
260
  scalePlaylistUrl(streamId: string): string;
261
+ /**
262
+ * Realtime captions SSE URL. Same play token as media — the engine's
263
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
264
+ * read its captions with zero extra credential.
265
+ */
266
+ captionsUrl(streamId: string, lang: string): string;
219
267
  private pathFor;
220
268
  /**
221
269
  * Run the session offer/answer exchange. Throws a {@link MebiusError} with a
@@ -315,11 +363,64 @@ declare class MebiusPlayer extends TypedEmitter<PlayerEventMap> {
315
363
  * drags to zero also survives a later unmute at the element level.
316
364
  */
317
365
  setVolume(volume: number): void;
366
+ /**
367
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
368
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
369
+ *
370
+ * This is what {@link MebiusClient.createCaptions} compares against a
371
+ * segment's `epochMs` to know when it is due. Delegating to the transport
372
+ * rather than reading the element directly is what keeps this correct across
373
+ * a route failover: the player may switch from HLS to FLV mid-session, and
374
+ * the clock source has to follow.
375
+ */
376
+ currentEpochMs(): number | null;
318
377
  private attach;
319
378
  private startStats;
320
379
  private stopStats;
321
380
  }
322
381
 
382
+ /**
383
+ * Realtime captions — subscribes to the engine's caption SSE feed and emits
384
+ * segments on the video's own timeline, not on arrival order.
385
+ *
386
+ * The engine (mebius-stream-engine, `internal/caption`) does the ASR/translate
387
+ * work and starts sending segments the moment a sentence finishes — which is
388
+ * BEFORE the viewer's player, sitting behind the live edge by however much its
389
+ * transport buffers, has shown the matching frame. Rendering on arrival would
390
+ * show the caption before the streamer says it. So every segment is buffered by
391
+ * `epochMs` (when the audio was actually spoken, a wall clock) and released only
392
+ * once {@link MebiusPlayer.currentEpochMs} reaches it. See
393
+ * mebius-stream-engine/docs/INTEGRATION.md §6.
394
+ */
395
+
396
+ /**
397
+ * Subscribes to one stream's caption feed. Create with
398
+ * {@link MebiusClient.createCaptions}, `start()` it, and listen for `"segment"`
399
+ * / `"cleared"`.
400
+ *
401
+ * Starting the caption SESSION (`captions/start`, which spends money) is a
402
+ * separate, server-side call — this class only ever reads the feed a session
403
+ * already produces. That split is deliberate: the control endpoint needs an API
404
+ * key, which must never reach a browser.
405
+ */
406
+ declare class MebiusCaptions extends TypedEmitter<CaptionsEventMap> {
407
+ private readonly signaling;
408
+ private readonly player;
409
+ private readonly opts;
410
+ private es;
411
+ private timer;
412
+ private readonly pending;
413
+ private readonly shown;
414
+ /** @internal */
415
+ constructor(signaling: SignalingClient, player: MebiusPlayer, opts: CaptionsOptions);
416
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
417
+ start(streamId: string): void;
418
+ /** Close the connection and drop all buffered segments. */
419
+ stop(): void;
420
+ private onFrame;
421
+ private tick;
422
+ }
423
+
323
424
  /**
324
425
  * A live connection to Mebius. Obtain one from {@link Mebius.connect}, then
325
426
  * create broadcasters and players from it.
@@ -352,6 +453,19 @@ declare class MebiusClient extends TypedEmitter<ClientEventMap> {
352
453
  * black frame to a live audience, so it belongs here rather than in every app.
353
454
  */
354
455
  createMonitor(): MebiusPlayer;
456
+ /**
457
+ * Subscribe to a stream's realtime captions.
458
+ *
459
+ * Reads the same feed a session already produces — it does NOT start the
460
+ * caption session itself. `captions/start` spends money and requires an API
461
+ * key, so it belongs to your own backend (see
462
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
463
+ * on for a stream. This only ever consumes what that call turned on.
464
+ *
465
+ * `player` must be the one showing `streamId`: captions are timed against its
466
+ * playhead, and a mismatched player would compare against the wrong clock.
467
+ */
468
+ createCaptions(player: MebiusPlayer, options: CaptionsOptions): MebiusCaptions;
355
469
  /** Close the connection and release resources. */
356
470
  disconnect(reason?: string): void;
357
471
  private assertConnected;
@@ -377,4 +491,4 @@ declare const Mebius: {
377
491
  _reset(): void;
378
492
  };
379
493
 
380
- export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
494
+ export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type CaptionSegment, type CaptionsEventMap, type CaptionsOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusCaptions, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
package/dist/index.d.ts CHANGED
@@ -123,6 +123,37 @@ interface PlaybackStats {
123
123
  /** Estimated end-to-end latency in milliseconds, if known. */
124
124
  latencyMs?: number;
125
125
  }
126
+ /** Options for {@link MebiusClient.createCaptions}. */
127
+ interface CaptionsOptions {
128
+ /**
129
+ * Which translation to read from each segment, e.g. `"id"`. Must match a
130
+ * `targetLangs` entry your backend passed to `captions/start` — the engine
131
+ * only ever sends the translations that session produced.
132
+ */
133
+ lang: string;
134
+ }
135
+ /**
136
+ * One caption segment, timed against a wall clock so it can be compared to
137
+ * {@link MebiusPlayer.currentEpochMs}.
138
+ */
139
+ interface CaptionSegment {
140
+ /** Stable id for this segment. A later revision replaces it in place. */
141
+ segmentId: string;
142
+ /** Revision counter. Only the highest-`rev` copy of a `segmentId` is kept. */
143
+ rev: number;
144
+ /** `"interim"` or `"final"`. Interim only arrives if the session enabled it. */
145
+ state: "interim" | "final";
146
+ /** Unix ms the audio was actually spoken. Compare against the playhead. */
147
+ epochMs: number;
148
+ /** How long the segment should stay on screen once due, in ms. */
149
+ durationMs: number;
150
+ /** Original transcript, in the source language. */
151
+ text: string;
152
+ /** The requested {@link CaptionsOptions.lang} translation, if produced yet. */
153
+ translation?: string;
154
+ /** Always `true`. Render a machine-generated indicator — never as a direct quote. */
155
+ machineGenerated: true;
156
+ }
126
157
  /** Canonical Mebius error codes surfaced to your app. */
127
158
  type MebiusErrorCode = "TOKEN_EXPIRED" | "PERMISSION_DENIED" | "CONNECTION_FAILED" | "NOT_CONNECTED" | "STREAM_NOT_FOUND" | "UNKNOWN";
128
159
 
@@ -167,6 +198,17 @@ type PlayerEventMap = {
167
198
  ended: void;
168
199
  stats: PlaybackStats;
169
200
  };
201
+ /** Event payloads emitted by {@link MebiusCaptions}. */
202
+ type CaptionsEventMap = {
203
+ /** A segment became due (its `epochMs` reached the playhead). Render it. */
204
+ segment: CaptionSegment;
205
+ /** A previously-shown segment aged out (playhead passed its window). Clear it. */
206
+ cleared: {
207
+ segmentId: string;
208
+ };
209
+ /** The SSE connection dropped. `EventSource` reconnects on its own. */
210
+ error: void;
211
+ };
170
212
  type Listener<T> = (payload: T) => void;
171
213
  /**
172
214
  * A tiny strongly-typed event emitter. `EventMap` maps each event name to its
@@ -216,6 +258,12 @@ declare class SignalingClient {
216
258
  deliveryUrl(path: string): string;
217
259
  /** Playlist URL for scale-mode playback. */
218
260
  scalePlaylistUrl(streamId: string): string;
261
+ /**
262
+ * Realtime captions SSE URL. Same play token as media — the engine's
263
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
264
+ * read its captions with zero extra credential.
265
+ */
266
+ captionsUrl(streamId: string, lang: string): string;
219
267
  private pathFor;
220
268
  /**
221
269
  * Run the session offer/answer exchange. Throws a {@link MebiusError} with a
@@ -315,11 +363,64 @@ declare class MebiusPlayer extends TypedEmitter<PlayerEventMap> {
315
363
  * drags to zero also survives a later unmute at the element level.
316
364
  */
317
365
  setVolume(volume: number): void;
366
+ /**
367
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
368
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
369
+ *
370
+ * This is what {@link MebiusClient.createCaptions} compares against a
371
+ * segment's `epochMs` to know when it is due. Delegating to the transport
372
+ * rather than reading the element directly is what keeps this correct across
373
+ * a route failover: the player may switch from HLS to FLV mid-session, and
374
+ * the clock source has to follow.
375
+ */
376
+ currentEpochMs(): number | null;
318
377
  private attach;
319
378
  private startStats;
320
379
  private stopStats;
321
380
  }
322
381
 
382
+ /**
383
+ * Realtime captions — subscribes to the engine's caption SSE feed and emits
384
+ * segments on the video's own timeline, not on arrival order.
385
+ *
386
+ * The engine (mebius-stream-engine, `internal/caption`) does the ASR/translate
387
+ * work and starts sending segments the moment a sentence finishes — which is
388
+ * BEFORE the viewer's player, sitting behind the live edge by however much its
389
+ * transport buffers, has shown the matching frame. Rendering on arrival would
390
+ * show the caption before the streamer says it. So every segment is buffered by
391
+ * `epochMs` (when the audio was actually spoken, a wall clock) and released only
392
+ * once {@link MebiusPlayer.currentEpochMs} reaches it. See
393
+ * mebius-stream-engine/docs/INTEGRATION.md §6.
394
+ */
395
+
396
+ /**
397
+ * Subscribes to one stream's caption feed. Create with
398
+ * {@link MebiusClient.createCaptions}, `start()` it, and listen for `"segment"`
399
+ * / `"cleared"`.
400
+ *
401
+ * Starting the caption SESSION (`captions/start`, which spends money) is a
402
+ * separate, server-side call — this class only ever reads the feed a session
403
+ * already produces. That split is deliberate: the control endpoint needs an API
404
+ * key, which must never reach a browser.
405
+ */
406
+ declare class MebiusCaptions extends TypedEmitter<CaptionsEventMap> {
407
+ private readonly signaling;
408
+ private readonly player;
409
+ private readonly opts;
410
+ private es;
411
+ private timer;
412
+ private readonly pending;
413
+ private readonly shown;
414
+ /** @internal */
415
+ constructor(signaling: SignalingClient, player: MebiusPlayer, opts: CaptionsOptions);
416
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
417
+ start(streamId: string): void;
418
+ /** Close the connection and drop all buffered segments. */
419
+ stop(): void;
420
+ private onFrame;
421
+ private tick;
422
+ }
423
+
323
424
  /**
324
425
  * A live connection to Mebius. Obtain one from {@link Mebius.connect}, then
325
426
  * create broadcasters and players from it.
@@ -352,6 +453,19 @@ declare class MebiusClient extends TypedEmitter<ClientEventMap> {
352
453
  * black frame to a live audience, so it belongs here rather than in every app.
353
454
  */
354
455
  createMonitor(): MebiusPlayer;
456
+ /**
457
+ * Subscribe to a stream's realtime captions.
458
+ *
459
+ * Reads the same feed a session already produces — it does NOT start the
460
+ * caption session itself. `captions/start` spends money and requires an API
461
+ * key, so it belongs to your own backend (see
462
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
463
+ * on for a stream. This only ever consumes what that call turned on.
464
+ *
465
+ * `player` must be the one showing `streamId`: captions are timed against its
466
+ * playhead, and a mismatched player would compare against the wrong clock.
467
+ */
468
+ createCaptions(player: MebiusPlayer, options: CaptionsOptions): MebiusCaptions;
355
469
  /** Close the connection and release resources. */
356
470
  disconnect(reason?: string): void;
357
471
  private assertConnected;
@@ -377,4 +491,4 @@ declare const Mebius: {
377
491
  _reset(): void;
378
492
  };
379
493
 
380
- export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
494
+ export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type CaptionSegment, type CaptionsEventMap, type CaptionsOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusCaptions, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
@@ -23901,19 +23901,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
23901
23901
  isInterstitial(item) {
23902
23902
  return !!(item != null && item.event);
23903
23903
  }
23904
- retreiveMediaSource(assetId, toSegment) {
23904
+ retreiveMediaSource(assetId, toSegment2) {
23905
23905
  const player = this.getAssetPlayer(assetId);
23906
23906
  if (player) {
23907
- this.transferMediaFromPlayer(player, toSegment);
23907
+ this.transferMediaFromPlayer(player, toSegment2);
23908
23908
  }
23909
23909
  }
23910
- transferMediaFromPlayer(player, toSegment) {
23910
+ transferMediaFromPlayer(player, toSegment2) {
23911
23911
  const appendInPlace = player.interstitial.appendInPlace;
23912
23912
  const playerMedia = player.media;
23913
23913
  if (appendInPlace && playerMedia === this.primaryMedia) {
23914
23914
  this.bufferingAsset = null;
23915
- if (!toSegment || this.isInterstitial(toSegment) && !toSegment.event.appendInPlace) {
23916
- if (toSegment && playerMedia) {
23915
+ if (!toSegment2 || this.isInterstitial(toSegment2) && !toSegment2.event.appendInPlace) {
23916
+ if (toSegment2 && playerMedia) {
23917
23917
  this.detachedData = {
23918
23918
  media: playerMedia
23919
23919
  };
@@ -23923,7 +23923,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
23923
23923
  const attachMediaSourceData = player.transferMedia();
23924
23924
  this.log(`transfer MediaSource from ${player} ${stringify(attachMediaSourceData)}`);
23925
23925
  this.detachedData = attachMediaSourceData;
23926
- } else if (toSegment && playerMedia) {
23926
+ } else if (toSegment2 && playerMedia) {
23927
23927
  this.shouldPlay || (this.shouldPlay = !playerMedia.paused);
23928
23928
  }
23929
23929
  }
@@ -24942,13 +24942,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
24942
24942
  });
24943
24943
  return player;
24944
24944
  }
24945
- clearInterstitial(interstitial, toSegment) {
24946
- this.clearAssetPlayers(interstitial, toSegment);
24945
+ clearInterstitial(interstitial, toSegment2) {
24946
+ this.clearAssetPlayers(interstitial, toSegment2);
24947
24947
  interstitial.reset();
24948
24948
  }
24949
- clearAssetPlayers(interstitial, toSegment) {
24949
+ clearAssetPlayers(interstitial, toSegment2) {
24950
24950
  interstitial.assetList.forEach((asset) => {
24951
- this.clearAssetPlayer(asset.identifier, toSegment);
24951
+ this.clearAssetPlayer(asset.identifier, toSegment2);
24952
24952
  });
24953
24953
  }
24954
24954
  resetAssetPlayer(assetId) {
@@ -24960,12 +24960,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
24960
24960
  player.resetDetails();
24961
24961
  }
24962
24962
  }
24963
- clearAssetPlayer(assetId, toSegment) {
24963
+ clearAssetPlayer(assetId, toSegment2) {
24964
24964
  const playerIndex = this.getAssetPlayerQueueIndex(assetId);
24965
24965
  if (playerIndex !== -1) {
24966
24966
  const player = this.playerQueue[playerIndex];
24967
- this.log(`clear ${player} toSegment: ${toSegment ? segmentToString(toSegment) : toSegment}`);
24968
- this.transferMediaFromPlayer(player, toSegment);
24967
+ this.log(`clear ${player} toSegment: ${toSegment2 ? segmentToString(toSegment2) : toSegment2}`);
24968
+ this.transferMediaFromPlayer(player, toSegment2);
24969
24969
  this.playerQueue.splice(playerIndex, 1);
24970
24970
  player.destroy();
24971
24971
  }
@@ -42609,6 +42609,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42609
42609
  __export(index_exports, {
42610
42610
  Mebius: () => Mebius,
42611
42611
  MebiusBroadcaster: () => MebiusBroadcaster,
42612
+ MebiusCaptions: () => MebiusCaptions,
42612
42613
  MebiusClient: () => MebiusClient,
42613
42614
  MebiusError: () => MebiusError,
42614
42615
  MebiusPlayer: () => MebiusPlayer,
@@ -42922,6 +42923,9 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42922
42923
  };
42923
42924
 
42924
42925
  // src/internal/scale-view-transport.ts
42926
+ function retryWarmupNotFound(cfg, retryCount, res, retry) {
42927
+ return retry || retryCount < (cfg?.maxNumRetry ?? 0) && res?.code === 404;
42928
+ }
42925
42929
  var HlsViewTransport = class {
42926
42930
  /**
42927
42931
  * deliveryPath, when given, is a gateway-relative path from the gateway's own
@@ -42969,7 +42973,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42969
42973
  this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
42970
42974
  return;
42971
42975
  }
42972
- const hls = new Hls2({ maxLiveSyncPlaybackRate: 1.1 });
42976
+ const hls = new Hls2({
42977
+ maxLiveSyncPlaybackRate: 1.1,
42978
+ manifestLoadPolicy: {
42979
+ default: {
42980
+ maxTimeToFirstByteMs: 1e4,
42981
+ maxLoadTimeMs: 2e4,
42982
+ timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 },
42983
+ errorRetry: {
42984
+ maxNumRetry: 5,
42985
+ retryDelayMs: 500,
42986
+ maxRetryDelayMs: 2e3,
42987
+ shouldRetry: (cfg, retryCount, _isTimeout, res, retry) => retryWarmupNotFound(cfg, retryCount, res, retry)
42988
+ }
42989
+ }
42990
+ }
42991
+ });
42973
42992
  this.hls = hls;
42974
42993
  hls.on(Hls2.Events.ERROR, (_evt, data) => {
42975
42994
  if (data.fatal) this.bufferingCb?.();
@@ -42997,6 +43016,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42997
43016
  framesPerSecond: 0
42998
43017
  };
42999
43018
  }
43019
+ /**
43020
+ * hls.js exposes `playingDate` straight from the segment the element is
43021
+ * currently rendering, derived from the playlist's `EXT-X-PROGRAM-DATE-TIME`
43022
+ * (MediaMTX writes it). Safari's native player has no such property, but
43023
+ * `getStartDate()` (the wall-clock time of the playlist's first segment) plus
43024
+ * elapsed `currentTime` is the same clock by construction.
43025
+ */
43026
+ playheadEpochMs() {
43027
+ if (this.hls) return this.hls.playingDate?.getTime() ?? null;
43028
+ const video = this.video;
43029
+ if (video?.getStartDate) {
43030
+ const start = video.getStartDate().getTime();
43031
+ if (Number.isFinite(start)) return start + video.currentTime * 1e3;
43032
+ }
43033
+ return null;
43034
+ }
43000
43035
  };
43001
43036
 
43002
43037
  // src/internal/balanced-view-transport.ts
@@ -43368,6 +43403,82 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43368
43403
  return c;
43369
43404
  }
43370
43405
 
43406
+ // src/captions.ts
43407
+ var TICK_MS = 100;
43408
+ var STALE_MS = 5e3;
43409
+ var MebiusCaptions = class extends TypedEmitter {
43410
+ /** @internal */
43411
+ constructor(signaling, player, opts) {
43412
+ super();
43413
+ this.signaling = signaling;
43414
+ this.player = player;
43415
+ this.opts = opts;
43416
+ this.es = null;
43417
+ this.timer = null;
43418
+ this.pending = /* @__PURE__ */ new Map();
43419
+ this.shown = /* @__PURE__ */ new Set();
43420
+ }
43421
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
43422
+ start(streamId) {
43423
+ if (this.es) return;
43424
+ const url = this.signaling.captionsUrl(streamId, this.opts.lang);
43425
+ const es = new EventSource(url);
43426
+ es.onmessage = (ev) => this.onFrame(ev);
43427
+ es.onerror = () => this.emit("error", void 0);
43428
+ this.es = es;
43429
+ this.timer = setInterval(() => this.tick(), TICK_MS);
43430
+ }
43431
+ /** Close the connection and drop all buffered segments. */
43432
+ stop() {
43433
+ this.es?.close();
43434
+ this.es = null;
43435
+ if (this.timer) clearInterval(this.timer);
43436
+ this.timer = null;
43437
+ this.pending.clear();
43438
+ this.shown.clear();
43439
+ }
43440
+ onFrame(ev) {
43441
+ let frame;
43442
+ try {
43443
+ frame = JSON.parse(ev.data);
43444
+ } catch {
43445
+ return;
43446
+ }
43447
+ if (frame.type !== "caption" || !frame.segmentId) return;
43448
+ const prev = this.pending.get(frame.segmentId);
43449
+ if (prev && (frame.rev ?? 0) < (prev.rev ?? 0)) return;
43450
+ this.pending.set(frame.segmentId, frame);
43451
+ }
43452
+ tick() {
43453
+ const now2 = this.player.currentEpochMs();
43454
+ if (now2 == null) return;
43455
+ for (const [id, frame] of this.pending) {
43456
+ const due = frame.epochMs ?? 0;
43457
+ if (due > now2) continue;
43458
+ if (due < now2 - STALE_MS) {
43459
+ this.pending.delete(id);
43460
+ if (this.shown.delete(id)) this.emit("cleared", { segmentId: id });
43461
+ continue;
43462
+ }
43463
+ this.shown.add(id);
43464
+ this.emit("segment", toSegment(id, frame, this.opts.lang));
43465
+ if (frame.state === "final") this.pending.delete(id);
43466
+ }
43467
+ }
43468
+ };
43469
+ function toSegment(segmentId, frame, lang) {
43470
+ return {
43471
+ segmentId,
43472
+ rev: frame.rev ?? 0,
43473
+ state: frame.state === "final" ? "final" : "interim",
43474
+ epochMs: frame.epochMs ?? 0,
43475
+ durationMs: frame.durationMs ?? 0,
43476
+ text: frame.text ?? "",
43477
+ translation: frame.translations?.[lang],
43478
+ machineGenerated: true
43479
+ };
43480
+ }
43481
+
43371
43482
  // src/internal/freeze-clock.ts
43372
43483
  var FreezeClock = class {
43373
43484
  constructor(now2 = Date.now) {
@@ -43537,6 +43648,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43537
43648
  this.video.volume = v;
43538
43649
  this.video.muted = v === 0;
43539
43650
  }
43651
+ /**
43652
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
43653
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
43654
+ *
43655
+ * This is what {@link MebiusClient.createCaptions} compares against a
43656
+ * segment's `epochMs` to know when it is due. Delegating to the transport
43657
+ * rather than reading the element directly is what keeps this correct across
43658
+ * a route failover: the player may switch from HLS to FLV mid-session, and
43659
+ * the clock source has to follow.
43660
+ */
43661
+ currentEpochMs() {
43662
+ return this.transport?.playheadEpochMs?.() ?? null;
43663
+ }
43540
43664
  attach(transport) {
43541
43665
  transport.onEnded(() => {
43542
43666
  if (this.transport !== transport) return;
@@ -43631,6 +43755,16 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43631
43755
  scalePlaylistUrl(streamId) {
43632
43756
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
43633
43757
  }
43758
+ /**
43759
+ * Realtime captions SSE URL. Same play token as media — the engine's
43760
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
43761
+ * read its captions with zero extra credential.
43762
+ */
43763
+ captionsUrl(streamId, lang) {
43764
+ return this.withToken(
43765
+ `${this.base()}/live/${encodeURIComponent(streamId)}/captions?lang=${encodeURIComponent(lang)}`
43766
+ );
43767
+ }
43634
43768
  // Maps a neutral session kind to the concrete signaling path segment. This
43635
43769
  // mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
43636
43770
  // the protocol names never appear in any exported type signature.
@@ -43754,6 +43888,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43754
43888
  this.assertConnected();
43755
43889
  return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
43756
43890
  }
43891
+ /**
43892
+ * Subscribe to a stream's realtime captions.
43893
+ *
43894
+ * Reads the same feed a session already produces — it does NOT start the
43895
+ * caption session itself. `captions/start` spends money and requires an API
43896
+ * key, so it belongs to your own backend (see
43897
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
43898
+ * on for a stream. This only ever consumes what that call turned on.
43899
+ *
43900
+ * `player` must be the one showing `streamId`: captions are timed against its
43901
+ * playhead, and a mismatched player would compare against the wrong clock.
43902
+ */
43903
+ createCaptions(player, options) {
43904
+ this.assertConnected();
43905
+ return new MebiusCaptions(this.signaling, player, options);
43906
+ }
43757
43907
  /** Close the connection and release resources. */
43758
43908
  disconnect(reason) {
43759
43909
  if (this.expiryTimer) clearTimeout(this.expiryTimer);