avbridge 2.10.0 → 2.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-EQE6AR75.cjs} +4 -4
  3. package/dist/{avi-2ILLBNPQ.cjs.map → avi-EQE6AR75.cjs.map} +1 -1
  4. package/dist/{avi-RWWPN2PR.js → avi-NNHH4AAA.js} +3 -3
  5. package/dist/{avi-JXU4GQL2.js.map → avi-NNHH4AAA.js.map} +1 -1
  6. package/dist/{avi-JXU4GQL2.js → avi-S7EY54YA.js} +3 -3
  7. package/dist/{avi-RWWPN2PR.js.map → avi-S7EY54YA.js.map} +1 -1
  8. package/dist/{avi-2ILLBNPQ.cjs → avi-Y3N325WZ.cjs} +4 -4
  9. package/dist/{avi-B5CQYB7L.cjs.map → avi-Y3N325WZ.cjs.map} +1 -1
  10. package/dist/{chunk-GYIJU44C.js → chunk-2LNXMGT6.js} +5 -5
  11. package/dist/{chunk-GYIJU44C.js.map → chunk-2LNXMGT6.js.map} +1 -1
  12. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  13. package/dist/chunk-3AI5WFFN.js.map +1 -0
  14. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  15. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-5Y5BTB5D.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-5Y5BTB5D.js.map} +1 -1
  18. package/dist/{chunk-NQULEIA3.cjs → chunk-7EF4VTUS.cjs} +36 -28
  19. package/dist/chunk-7EF4VTUS.cjs.map +1 -0
  20. package/dist/{chunk-5KVLE6YI.js → chunk-EDDWAN2L.js} +3 -2
  21. package/dist/chunk-EDDWAN2L.js.map +1 -0
  22. package/dist/{chunk-OTFS7DC4.cjs → chunk-GJBNLPGI.cjs} +14 -14
  23. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-GJBNLPGI.cjs.map} +1 -1
  24. package/dist/{chunk-BYGZN4Z5.cjs → chunk-HBHSUGNI.cjs} +5 -5
  25. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-HBHSUGNI.cjs.map} +1 -1
  26. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  27. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  28. package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
  29. package/dist/chunk-WRKO6Q42.cjs.map +1 -0
  30. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  31. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  32. package/dist/{chunk-3GKM5DFM.js → chunk-Z26PXRUY.js} +18 -10
  33. package/dist/chunk-Z26PXRUY.js.map +1 -0
  34. package/dist/element-browser.js +65 -19
  35. package/dist/element-browser.js.map +1 -1
  36. package/dist/element.cjs +21 -8
  37. package/dist/element.cjs.map +1 -1
  38. package/dist/element.d.cts +1 -1
  39. package/dist/element.d.ts +1 -1
  40. package/dist/element.js +20 -7
  41. package/dist/element.js.map +1 -1
  42. package/dist/index.cjs +23 -23
  43. package/dist/index.d.cts +2 -2
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +10 -10
  46. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  47. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  48. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  49. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  50. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  51. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  52. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  53. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  54. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  55. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  56. package/dist/player.cjs +166 -33
  57. package/dist/player.cjs.map +1 -1
  58. package/dist/player.d.cts +36 -0
  59. package/dist/player.d.ts +36 -0
  60. package/dist/player.js +162 -29
  61. package/dist/player.js.map +1 -1
  62. package/dist/remux-7TA4FKTY.js +10 -0
  63. package/dist/{remux-56V7LDAD.js.map → remux-7TA4FKTY.js.map} +1 -1
  64. package/dist/remux-VPKCLHHM.cjs +35 -0
  65. package/dist/{remux-KUS5GIL6.cjs.map → remux-VPKCLHHM.cjs.map} +1 -1
  66. package/dist/subtitles-5H24MEBJ.js +4 -0
  67. package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
  68. package/dist/subtitles-HMVGWTU2.cjs +29 -0
  69. package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
  70. package/package.json +1 -1
  71. package/src/element/avbridge-player.ts +128 -18
  72. package/src/element/avbridge-subtitles.ts +273 -0
  73. package/src/element/avbridge-video.ts +21 -1
  74. package/src/element/player-styles.ts +13 -1
  75. package/src/player.ts +3 -3
  76. package/src/strategies/fallback/audio-output.ts +10 -0
  77. package/src/subtitles/index.ts +2 -0
  78. package/src/types.ts +15 -0
  79. package/src/util/libav-http-reader.ts +58 -19
  80. package/dist/chunk-3GKM5DFM.js.map +0 -1
  81. package/dist/chunk-5KVLE6YI.js.map +0 -1
  82. package/dist/chunk-DCSOQH2N.js.map +0 -1
  83. package/dist/chunk-NQULEIA3.cjs.map +0 -1
  84. package/dist/chunk-S4WAZC2T.cjs.map +0 -1
  85. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  86. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  87. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  88. package/dist/remux-56V7LDAD.js +0 -10
  89. package/dist/remux-KUS5GIL6.cjs +0 -35
  90. package/dist/subtitles-4T74JRGT.js +0 -4
  91. package/dist/subtitles-QUH4LPI4.cjs +0 -29
@@ -856,15 +856,35 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
856
856
  sidecarUrl: subtitle.url,
857
857
  };
858
858
  this._subtitleTracks.push(track);
859
+ // eslint-disable-next-line no-console
860
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
859
861
  await attachSubtitleTracks(
860
862
  this._videoEl,
861
863
  this._subtitleTracks,
862
864
  undefined,
863
865
  (err, t) => {
864
866
  // eslint-disable-next-line no-console
865
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
867
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
866
868
  },
867
869
  );
870
+ // Enable the newly-added track so it renders immediately. On native
871
+ // strategy the <video>'s textTrack must be mode="showing"; on canvas
872
+ // strategies the renderer's watchTextTracks picks it up from the
873
+ // hidden-mode textTracks.
874
+ const textTracks = this._videoEl.textTracks;
875
+ for (let i = 0; i < textTracks.length; i++) {
876
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
877
+ textTracks[i].mode = "showing";
878
+ // eslint-disable-next-line no-console
879
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
880
+ break;
881
+ }
882
+ }
883
+ // Notify the settings sheet so it rebuilds with the new track.
884
+ this._dispatch("trackschange", {
885
+ audioTracks: this._audioTracks,
886
+ subtitleTracks: this.subtitleTracks,
887
+ });
868
888
  }
869
889
 
870
890
  /**
@@ -286,6 +286,12 @@ export const PLAYER_STYLES = /* css */ `
286
286
  display: flex;
287
287
  align-items: center;
288
288
  cursor: pointer;
289
+ /* Claim all touch gestures on the seek bar. Without this, Android
290
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
291
+ * scroll candidates and cancel pointermove once the gesture
292
+ * resolves, breaking scrub. touch-action must be set in CSS —
293
+ * preventDefault() on pointerdown is too late. */
294
+ touch-action: none;
289
295
  }
290
296
 
291
297
  .avp-seek-track {
@@ -303,7 +309,13 @@ export const PLAYER_STYLES = /* css */ `
303
309
 
304
310
  .avp-seek-buffered {
305
311
  position: absolute;
306
- left: 0;
312
+ inset: 0;
313
+ pointer-events: none;
314
+ }
315
+
316
+ .avp-seek-buffered-range {
317
+ position: absolute;
318
+ top: 0;
307
319
  height: 100%;
308
320
  background: rgba(255, 255, 255, 0.35);
309
321
  border-radius: inherit;
package/src/player.ts CHANGED
@@ -132,9 +132,9 @@ export class UnifiedPlayer {
132
132
  private readonly options: CreatePlayerOptions,
133
133
  private readonly registry: PluginRegistry,
134
134
  ) {
135
- const { requestInit, fetchFn } = options;
136
- if (requestInit || fetchFn) {
137
- this.transport = { requestInit, fetchFn };
135
+ const { requestInit, fetchFn, cacheBytes } = options;
136
+ if (requestInit || fetchFn || cacheBytes !== undefined) {
137
+ this.transport = { requestInit, fetchFn, cacheBytes };
138
138
  }
139
139
  }
140
140
 
@@ -281,6 +281,10 @@ export class AudioOutput implements ClockSource {
281
281
  await this.ctx.resume();
282
282
  }
283
283
 
284
+ // Reconnect the gain node — pause() disconnects it to cut off
285
+ // in-flight audio instantly. Safe to call even if already connected.
286
+ try { this.gain.connect(this.ctx.destination); } catch { /* ignore */ }
287
+
284
288
  if (this.state === "paused") {
285
289
  // Resume: media time should continue from where we paused. ctx.currentTime
286
290
  // is preserved across suspend/resume, so re-anchoring it to "now" with
@@ -317,6 +321,12 @@ export class AudioOutput implements ClockSource {
317
321
  this.mediaTimeOfAnchor = this.now();
318
322
  this.state = "paused";
319
323
  if (this.noAudio) return;
324
+ // Disconnect the gain node immediately so any in-flight scheduled
325
+ // buffers are silenced instantly. ctx.suspend() is async and
326
+ // already-started AudioBufferSourceNodes keep playing until the
327
+ // context actually suspends — without the disconnect, audio bleeds
328
+ // through for ~200ms after pause().
329
+ try { this.gain.disconnect(); } catch { /* ignore */ }
320
330
  if (this.ctx.state === "running") {
321
331
  await this.ctx.suspend();
322
332
  }
@@ -110,6 +110,8 @@ export async function attachSubtitleTracks(
110
110
 
111
111
  for (const t of tracks) {
112
112
  if (!t.sidecarUrl) continue;
113
+ // eslint-disable-next-line no-console
114
+ console.log(`[avbridge:subs] attaching track id=${t.id} format=${t.format} url=${t.sidecarUrl.slice(0, 60)}`);
113
115
  try {
114
116
  let url = t.sidecarUrl;
115
117
  if (t.format === "srt") {
package/src/types.ts CHANGED
@@ -287,6 +287,14 @@ export interface CreatePlayerOptions {
287
287
  * for interceptors, logging, or environments without a global fetch.
288
288
  */
289
289
  fetchFn?: FetchFn;
290
+ /**
291
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
292
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
293
+ * app plays seek-heavy legacy-container media from URLs — hot regions
294
+ * (header/moov, tail index, current window) stay resident instead of
295
+ * being re-fetched on every bounce.
296
+ */
297
+ cacheBytes?: number;
290
298
  }
291
299
 
292
300
  /** Signature-compatible with `globalThis.fetch`. */
@@ -296,6 +304,13 @@ export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<
296
304
  export interface TransportConfig {
297
305
  requestInit?: RequestInit;
298
306
  fetchFn?: FetchFn;
307
+ /**
308
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
309
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
310
+ * values help seek-heavy network playback keep hot regions
311
+ * (header/moov, tail index, current read) resident.
312
+ */
313
+ cacheBytes?: number;
299
314
  }
300
315
 
301
316
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
@@ -32,8 +32,13 @@
32
32
  * clamped to `[256 KB, 1 MB]`. Small reads get amortized; pathological
33
33
  * large requests don't OOM us.
34
34
  *
35
- * - **Last-block cache.** Only the most-recent fetched block is kept.
36
- * Re-fetches via Range are cheap; an LRU cache is post-1.0.
35
+ * - **LRU block cache.** Fetched blocks are kept in a Map keyed by start
36
+ * position, bounded by a byte budget (default 8 MB, configurable via
37
+ * `cacheBytes`). Map insertion order doubles as recency; re-accessing
38
+ * a block promotes it via delete+set. Eviction walks oldest-first
39
+ * until total bytes fit the budget. Typical seek pattern has three
40
+ * hot regions — header/moov at the front, index at the tail, current
41
+ * read position — all of which fit comfortably under the default.
37
42
  *
38
43
  * - **Safe detach.** `detach()` clears `libav.onblockread`, sets a
39
44
  * destroyed flag, and ignores any in-flight fetch resolutions so we
@@ -42,6 +47,7 @@
42
47
 
43
48
  const MIN_READ = 256 * 1024;
44
49
  const MAX_READ = 1 * 1024 * 1024;
50
+ const DEFAULT_CACHE_BYTES = 8 * 1024 * 1024;
45
51
 
46
52
  interface LibavLike {
47
53
  mkblockreaderdev(name: string, size: number): Promise<void>;
@@ -69,6 +75,12 @@ export interface AttachLibavHttpReaderOptions {
69
75
  requestInit?: RequestInit;
70
76
  /** Override fetch (for testing). Defaults to globalThis.fetch. */
71
77
  fetchFn?: typeof fetch;
78
+ /**
79
+ * Byte budget for the LRU block cache. Defaults to 8 MB. Set to `0`
80
+ * to disable caching. Raise this (e.g. 32 MB) for apps that play
81
+ * seek-heavy legacy-container media over the network.
82
+ */
83
+ cacheBytes?: number;
72
84
  }
73
85
 
74
86
  /**
@@ -108,6 +120,7 @@ export async function prepareLibavInput(
108
120
  const handle = await attachLibavHttpReader(libav, filename, source.url, {
109
121
  requestInit: transport?.requestInit,
110
122
  fetchFn: transport?.fetchFn,
123
+ cacheBytes: transport?.cacheBytes,
111
124
  });
112
125
  return {
113
126
  filename,
@@ -192,9 +205,12 @@ export async function attachLibavHttpReader(
192
205
  // ── State ───────────────────────────────────────────────────────────────
193
206
 
194
207
  let detached = false;
195
- // Most-recently fetched block. Cached so re-reads of the same region
196
- // (e.g. demuxer re-walks the header) don't issue another HTTP request.
197
- let cached: { pos: number; bytes: Uint8Array } | null = null;
208
+ // LRU cache of fetched blocks, keyed by start position. Map insertion
209
+ // order = recency. Bounded by `cacheBudget` bytes; evicts oldest-first
210
+ // on overflow. Set budget to 0 to disable caching.
211
+ const cache = new Map<number, Uint8Array>();
212
+ let cacheBytes = 0;
213
+ const cacheBudget = Math.max(0, options.cacheBytes ?? DEFAULT_CACHE_BYTES);
198
214
  // The currently in-flight fetch, if any. Used both for serialization
199
215
  // (we await this before starting another) and for in-flight dedup.
200
216
  let inflight: Promise<void> | null = null;
@@ -206,17 +222,39 @@ export async function attachLibavHttpReader(
206
222
  return doubled;
207
223
  }
208
224
 
209
- /** True if the cached block fully covers `[pos, pos+length)`. */
210
- function cacheCovers(pos: number, length: number): boolean {
211
- if (!cached) return false;
212
- return pos >= cached.pos && pos + length <= cached.pos + cached.bytes.byteLength;
225
+ /**
226
+ * Look up a cached block that fully covers `[pos, pos+length)`. On hit,
227
+ * promote the block to most-recent and return the slice. On miss, null.
228
+ */
229
+ function cacheLookup(pos: number, length: number): Uint8Array | null {
230
+ for (const [blockPos, bytes] of cache) {
231
+ if (pos >= blockPos && pos + length <= blockPos + bytes.byteLength) {
232
+ cache.delete(blockPos);
233
+ cache.set(blockPos, bytes);
234
+ const offset = pos - blockPos;
235
+ return bytes.subarray(offset, offset + length);
236
+ }
237
+ }
238
+ return null;
213
239
  }
214
240
 
215
- /** Slice the requested window out of the cached block. */
216
- function sliceFromCache(pos: number, length: number): Uint8Array {
217
- if (!cached) throw new Error("sliceFromCache called with no cache");
218
- const offset = pos - cached.pos;
219
- return cached.bytes.subarray(offset, offset + length);
241
+ /** Insert a fetched block; evict least-recently-used until under budget. */
242
+ function cacheInsert(pos: number, bytes: Uint8Array): void {
243
+ const existing = cache.get(pos);
244
+ if (existing) {
245
+ cacheBytes -= existing.byteLength;
246
+ cache.delete(pos);
247
+ }
248
+ cache.set(pos, bytes);
249
+ cacheBytes += bytes.byteLength;
250
+ while (cacheBytes > cacheBudget && cache.size > 0) {
251
+ const oldestKey = cache.keys().next().value as number | undefined;
252
+ if (oldestKey === undefined) break;
253
+ const oldest = cache.get(oldestKey);
254
+ if (!oldest) break;
255
+ cache.delete(oldestKey);
256
+ cacheBytes -= oldest.byteLength;
257
+ }
220
258
  }
221
259
 
222
260
  /** Fetch one Range and update the cache. */
@@ -235,7 +273,7 @@ export async function attachLibavHttpReader(
235
273
  );
236
274
  }
237
275
  const buf = new Uint8Array(await res.arrayBuffer());
238
- cached = { pos, bytes: buf };
276
+ cacheInsert(pos, buf);
239
277
  return buf;
240
278
  }
241
279
 
@@ -252,9 +290,9 @@ export async function attachLibavHttpReader(
252
290
  if (detached) return;
253
291
 
254
292
  // Cache hit — reply directly without a network round-trip.
255
- if (cacheCovers(pos, length)) {
256
- const data = sliceFromCache(pos, length);
257
- try { await libav.ff_block_reader_dev_send(name, pos, data); } catch { /* ignore — libav may have torn down */ }
293
+ const hit = cacheLookup(pos, length);
294
+ if (hit) {
295
+ try { await libav.ff_block_reader_dev_send(name, pos, hit); } catch { /* ignore — libav may have torn down */ }
258
296
  return;
259
297
  }
260
298
 
@@ -312,7 +350,8 @@ export async function attachLibavHttpReader(
312
350
  try { await inflight; } catch { /* ignore */ }
313
351
  }
314
352
  // Drop the cache and unlink the virtual file.
315
- cached = null;
353
+ cache.clear();
354
+ cacheBytes = 0;
316
355
  try { await libav.unlinkreadaheadfile(filename); } catch { /* ignore */ }
317
356
  },
318
357
  };