avbridge 2.2.0 → 2.3.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 (121) hide show
  1. package/CHANGELOG.md +125 -1
  2. package/NOTICE.md +2 -2
  3. package/README.md +100 -74
  4. package/THIRD_PARTY_LICENSES.md +2 -2
  5. package/dist/avi-2JPBSHGA.js +183 -0
  6. package/dist/avi-2JPBSHGA.js.map +1 -0
  7. package/dist/avi-F6WZJK5T.cjs +185 -0
  8. package/dist/avi-F6WZJK5T.cjs.map +1 -0
  9. package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
  10. package/dist/avi-NJXAXUXK.js.map +1 -0
  11. package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
  12. package/dist/avi-W6L3BTWU.cjs.map +1 -0
  13. package/dist/{chunk-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
  14. package/dist/chunk-2PGRFCWB.js.map +1 -0
  15. package/dist/chunk-5YAWWKA3.js +18 -0
  16. package/dist/chunk-5YAWWKA3.js.map +1 -0
  17. package/dist/chunk-6UUT4BEA.cjs +219 -0
  18. package/dist/chunk-6UUT4BEA.cjs.map +1 -0
  19. package/dist/{chunk-OE66B34H.cjs → chunk-7RGG6ME7.cjs} +562 -94
  20. package/dist/chunk-7RGG6ME7.cjs.map +1 -0
  21. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  22. package/dist/chunk-DCSOQH2N.js.map +1 -0
  23. package/dist/chunk-F3LQJKXK.cjs +20 -0
  24. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  25. package/dist/chunk-IAYKFGFG.js +200 -0
  26. package/dist/chunk-IAYKFGFG.js.map +1 -0
  27. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  28. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  29. package/dist/{chunk-C5VA5U5O.js → chunk-NV7ILLWH.js} +556 -92
  30. package/dist/chunk-NV7ILLWH.js.map +1 -0
  31. package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
  32. package/dist/chunk-QQXBPW72.js.map +1 -0
  33. package/dist/chunk-XKPSTC34.cjs +210 -0
  34. package/dist/chunk-XKPSTC34.cjs.map +1 -0
  35. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  36. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  37. package/dist/element-browser.js +631 -103
  38. package/dist/element-browser.js.map +1 -1
  39. package/dist/element.cjs +4 -4
  40. package/dist/element.d.cts +1 -1
  41. package/dist/element.d.ts +1 -1
  42. package/dist/element.js +3 -3
  43. package/dist/index.cjs +174 -26
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +48 -4
  46. package/dist/index.d.ts +48 -4
  47. package/dist/index.js +93 -12
  48. package/dist/index.js.map +1 -1
  49. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  50. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
  51. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  52. package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
  53. package/dist/{player-DUyvltvy.d.cts → player-B6WB74RD.d.cts} +63 -3
  54. package/dist/{player-DUyvltvy.d.ts → player-B6WB74RD.d.ts} +63 -3
  55. package/dist/player.cjs +5500 -0
  56. package/dist/player.cjs.map +1 -0
  57. package/dist/player.d.cts +649 -0
  58. package/dist/player.d.ts +649 -0
  59. package/dist/player.js +5498 -0
  60. package/dist/player.js.map +1 -0
  61. package/dist/source-73CAH6HW.cjs +28 -0
  62. package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
  63. package/dist/source-F656KYYV.js +3 -0
  64. package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
  65. package/dist/source-QJR3OHTW.js +3 -0
  66. package/dist/source-QJR3OHTW.js.map +1 -0
  67. package/dist/source-VB74JQ7Z.cjs +28 -0
  68. package/dist/source-VB74JQ7Z.cjs.map +1 -0
  69. package/dist/variant-routing-434STYAB.js +3 -0
  70. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  71. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  72. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  73. package/package.json +9 -1
  74. package/src/classify/rules.ts +27 -5
  75. package/src/convert/remux.ts +8 -0
  76. package/src/convert/transcode.ts +41 -8
  77. package/src/element/avbridge-player.ts +845 -0
  78. package/src/element/player-icons.ts +25 -0
  79. package/src/element/player-styles.ts +472 -0
  80. package/src/errors.ts +47 -0
  81. package/src/index.ts +23 -0
  82. package/src/player-element.ts +18 -0
  83. package/src/player.ts +127 -27
  84. package/src/plugins/builtin.ts +2 -2
  85. package/src/probe/avi.ts +4 -0
  86. package/src/probe/index.ts +40 -10
  87. package/src/strategies/fallback/audio-output.ts +31 -0
  88. package/src/strategies/fallback/decoder.ts +83 -2
  89. package/src/strategies/fallback/index.ts +34 -1
  90. package/src/strategies/fallback/variant-routing.ts +7 -13
  91. package/src/strategies/fallback/video-renderer.ts +129 -33
  92. package/src/strategies/hybrid/decoder.ts +131 -20
  93. package/src/strategies/hybrid/index.ts +36 -2
  94. package/src/strategies/remux/index.ts +13 -1
  95. package/src/strategies/remux/mse.ts +12 -2
  96. package/src/strategies/remux/pipeline.ts +6 -0
  97. package/src/subtitles/index.ts +7 -3
  98. package/src/types.ts +53 -1
  99. package/src/util/libav-http-reader.ts +5 -1
  100. package/src/util/source.ts +28 -8
  101. package/src/util/transport.ts +26 -0
  102. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  103. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  104. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  105. package/dist/avi-GCGM7OJI.js.map +0 -1
  106. package/dist/chunk-C5VA5U5O.js.map +0 -1
  107. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  108. package/dist/chunk-ILKDNBSE.js.map +0 -1
  109. package/dist/chunk-J5MCMN3S.js +0 -27
  110. package/dist/chunk-J5MCMN3S.js.map +0 -1
  111. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  112. package/dist/chunk-NZU7W256.cjs +0 -29
  113. package/dist/chunk-NZU7W256.cjs.map +0 -1
  114. package/dist/chunk-OE66B34H.cjs.map +0 -1
  115. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  116. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  117. package/dist/libav-http-reader-NQJVY273.js +0 -3
  118. package/dist/source-CN43EI7Z.cjs +0 -28
  119. package/dist/source-FFZ7TW2B.js +0 -3
  120. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  121. package/dist/variant-routing-JOBWXYKD.js +0 -3
@@ -31,6 +31,51 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  mod
32
32
  ));
33
33
 
34
+ // src/util/transport.ts
35
+ function mergeFetchInit(base, extra) {
36
+ if (!base && !extra) return void 0;
37
+ return {
38
+ ...base,
39
+ ...extra,
40
+ headers: {
41
+ ...base?.headers ?? {},
42
+ ...extra?.headers ?? {}
43
+ }
44
+ };
45
+ }
46
+ function fetchWith(transport) {
47
+ return transport?.fetchFn ?? globalThis.fetch;
48
+ }
49
+ var init_transport = __esm({
50
+ "src/util/transport.ts"() {
51
+ }
52
+ });
53
+
54
+ // src/errors.ts
55
+ var AvbridgeError, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_PROBE_FETCH_FAILED, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, ERR_LIBAV_NOT_REACHABLE, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED;
56
+ var init_errors = __esm({
57
+ "src/errors.ts"() {
58
+ AvbridgeError = class extends Error {
59
+ constructor(code, message, recovery, options) {
60
+ super(message, options);
61
+ this.code = code;
62
+ this.recovery = recovery;
63
+ }
64
+ code;
65
+ recovery;
66
+ name = "AvbridgeError";
67
+ };
68
+ ERR_PROBE_FAILED = "ERR_AVBRIDGE_PROBE_FAILED";
69
+ ERR_PROBE_UNKNOWN_CONTAINER = "ERR_AVBRIDGE_PROBE_UNKNOWN_CONTAINER";
70
+ ERR_PROBE_FETCH_FAILED = "ERR_AVBRIDGE_PROBE_FETCH_FAILED";
71
+ ERR_ALL_STRATEGIES_EXHAUSTED = "ERR_AVBRIDGE_ALL_STRATEGIES_EXHAUSTED";
72
+ ERR_PLAYER_NOT_READY = "ERR_AVBRIDGE_PLAYER_NOT_READY";
73
+ ERR_LIBAV_NOT_REACHABLE = "ERR_AVBRIDGE_LIBAV_NOT_REACHABLE";
74
+ ERR_MSE_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_NOT_SUPPORTED";
75
+ ERR_MSE_CODEC_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_CODEC_NOT_SUPPORTED";
76
+ }
77
+ });
78
+
34
79
  // src/util/source.ts
35
80
  var source_exports = {};
36
81
  __export(source_exports, {
@@ -43,7 +88,7 @@ __export(source_exports, {
43
88
  function isInMemorySource(source) {
44
89
  return source.kind === "blob";
45
90
  }
46
- async function normalizeSource(source) {
91
+ async function normalizeSource(source, transport) {
47
92
  if (source instanceof File) {
48
93
  return {
49
94
  kind: "blob",
@@ -66,22 +111,31 @@ async function normalizeSource(source) {
66
111
  }
67
112
  if (typeof source === "string" || source instanceof URL) {
68
113
  const url2 = source instanceof URL ? source.toString() : source;
69
- return await fetchUrlForSniff(url2, source);
114
+ return await fetchUrlForSniff(url2, source, transport);
70
115
  }
71
116
  throw new TypeError("unsupported source type");
72
117
  }
73
- async function fetchUrlForSniff(url2, originalSource) {
118
+ async function fetchUrlForSniff(url2, originalSource, transport) {
74
119
  const name = url2.split("/").pop()?.split("?")[0] ?? void 0;
120
+ const doFetch = fetchWith(transport);
75
121
  let res;
76
122
  try {
77
- res = await fetch(url2, {
123
+ res = await doFetch(url2, mergeFetchInit(transport?.requestInit, {
78
124
  headers: { Range: `bytes=0-${URL_SNIFF_RANGE_BYTES - 1}` }
79
- });
125
+ }));
80
126
  } catch (err) {
81
- throw new Error(`failed to fetch source ${url2}: ${err.message}`);
127
+ throw new AvbridgeError(
128
+ ERR_PROBE_FETCH_FAILED,
129
+ `Failed to fetch source ${url2}: ${err.message}`,
130
+ "Check that the URL is reachable and CORS is configured. If the source requires authentication, pass requestInit with credentials/headers."
131
+ );
82
132
  }
83
133
  if (!res.ok && res.status !== 206) {
84
- throw new Error(`failed to fetch source ${url2}: ${res.status} ${res.statusText}`);
134
+ throw new AvbridgeError(
135
+ ERR_PROBE_FETCH_FAILED,
136
+ `Failed to fetch source ${url2}: ${res.status} ${res.statusText}`,
137
+ res.status === 403 || res.status === 401 ? "The server rejected the request. Pass requestInit with the required Authorization header or credentials." : "Check that the URL is correct and the server is reachable."
138
+ );
85
139
  }
86
140
  let byteLength;
87
141
  const contentRange = res.headers.get("content-range");
@@ -186,6 +240,8 @@ async function readBlobBytes(blob, limit) {
186
240
  var SNIFF_BYTES_NEEDED, URL_SNIFF_RANGE_BYTES;
187
241
  var init_source = __esm({
188
242
  "src/util/source.ts"() {
243
+ init_transport();
244
+ init_errors();
189
245
  SNIFF_BYTES_NEEDED = 380;
190
246
  URL_SNIFF_RANGE_BYTES = 32 * 1024;
191
247
  }
@@ -29453,9 +29509,12 @@ __export(libav_http_reader_exports, {
29453
29509
  attachLibavHttpReader: () => attachLibavHttpReader,
29454
29510
  prepareLibavInput: () => prepareLibavInput
29455
29511
  });
29456
- async function prepareLibavInput(libav, filename, source) {
29512
+ async function prepareLibavInput(libav, filename, source, transport) {
29457
29513
  if (source.kind === "url") {
29458
- const handle = await attachLibavHttpReader(libav, filename, source.url);
29514
+ const handle = await attachLibavHttpReader(libav, filename, source.url, {
29515
+ requestInit: transport?.requestInit,
29516
+ fetchFn: transport?.fetchFn
29517
+ });
29459
29518
  return {
29460
29519
  filename,
29461
29520
  transport: "http-range",
@@ -30009,6 +30068,12 @@ function ffmpegToAvbridgeAudio(name) {
30009
30068
  return "sipr";
30010
30069
  case "atrac3":
30011
30070
  return "atrac3";
30071
+ case "dca":
30072
+ case "dts":
30073
+ return "dts";
30074
+ case "truehd":
30075
+ case "mlp":
30076
+ return "truehd";
30012
30077
  default:
30013
30078
  return name;
30014
30079
  }
@@ -31152,6 +31217,7 @@ async function safe(fn) {
31152
31217
  }
31153
31218
 
31154
31219
  // src/probe/index.ts
31220
+ init_errors();
31155
31221
  var MEDIABUNNY_CONTAINERS = /* @__PURE__ */ new Set([
31156
31222
  "mp4",
31157
31223
  "mov",
@@ -31164,12 +31230,22 @@ var MEDIABUNNY_CONTAINERS = /* @__PURE__ */ new Set([
31164
31230
  "adts",
31165
31231
  "mpegts"
31166
31232
  ]);
31167
- async function probe(source) {
31168
- const normalized = await normalizeSource(source);
31233
+ async function probe(source, transport) {
31234
+ const normalized = await normalizeSource(source, transport);
31169
31235
  const sniffed = await sniffNormalizedSource(normalized);
31170
31236
  if (MEDIABUNNY_CONTAINERS.has(sniffed)) {
31171
31237
  try {
31172
- return await probeWithMediabunny(normalized, sniffed);
31238
+ const result = await probeWithMediabunny(normalized, sniffed);
31239
+ const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
31240
+ if (hasUnknownCodec) {
31241
+ try {
31242
+ const { probeWithLibav: probeWithLibav2 } = await Promise.resolve().then(() => (init_avi(), avi_exports));
31243
+ return await probeWithLibav2(normalized, sniffed);
31244
+ } catch {
31245
+ return result;
31246
+ }
31247
+ }
31248
+ return result;
31173
31249
  } catch (mediabunnyErr) {
31174
31250
  console.warn(
31175
31251
  `[avbridge] mediabunny rejected ${sniffed} file, falling back to libav:`,
@@ -31181,8 +31257,10 @@ async function probe(source) {
31181
31257
  } catch (libavErr) {
31182
31258
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
31183
31259
  const lvMsg = libavErr instanceof Error ? libavErr.message : String(libavErr);
31184
- throw new Error(
31185
- `failed to probe ${sniffed} file. mediabunny: ${mbMsg}. libav fallback: ${lvMsg}.`
31260
+ throw new AvbridgeError(
31261
+ ERR_PROBE_FAILED,
31262
+ `Failed to probe ${sniffed.toUpperCase()} file. mediabunny: ${mbMsg}. libav: ${lvMsg}.`,
31263
+ "The file may be corrupt, truncated, or in an unsupported format. Enable AVBRIDGE_DEBUG for detailed logs."
31186
31264
  );
31187
31265
  }
31188
31266
  }
@@ -31193,8 +31271,17 @@ async function probe(source) {
31193
31271
  } catch (err) {
31194
31272
  const inner = err instanceof Error ? err.message : String(err);
31195
31273
  console.error("[avbridge] libav probe failed for", sniffed, "file:", err);
31196
- throw new Error(
31197
- sniffed === "unknown" ? `unable to probe source: container could not be identified, and the libav.js fallback also failed: ${inner || "(no message \u2014 see browser console for the original error)"}` : `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no message \u2014 see browser console for the original error)"}`
31274
+ if (sniffed === "unknown") {
31275
+ throw new AvbridgeError(
31276
+ ERR_PROBE_UNKNOWN_CONTAINER,
31277
+ `Unable to probe source: container format could not be identified. libav fallback: ${inner || "(no details)"}`,
31278
+ "The file may be corrupt or in a format avbridge doesn't recognize. Check the file plays in VLC or ffprobe."
31279
+ );
31280
+ }
31281
+ throw new AvbridgeError(
31282
+ ERR_LIBAV_NOT_REACHABLE,
31283
+ `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no details)"}`,
31284
+ "Install @libav.js/variant-webcodecs, or check that AVBRIDGE_LIBAV_BASE points to the correct path."
31198
31285
  );
31199
31286
  }
31200
31287
  }
@@ -31295,7 +31382,9 @@ var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
31295
31382
  "ra_144",
31296
31383
  "ra_288",
31297
31384
  "sipr",
31298
- "atrac3"
31385
+ "atrac3",
31386
+ "dts",
31387
+ "truehd"
31299
31388
  ]);
31300
31389
  var NATIVE_CONTAINERS = /* @__PURE__ */ new Set([
31301
31390
  "mp4",
@@ -31357,7 +31446,16 @@ function classifyContext(ctx) {
31357
31446
  reason: `video codec "${video.codec}" has no browser decoder; WASM fallback required`
31358
31447
  };
31359
31448
  }
31360
- if (audio && FALLBACK_AUDIO_CODECS.has(audio.codec)) {
31449
+ const audioNeedsFallback = audio && (FALLBACK_AUDIO_CODECS.has(audio.codec) || !NATIVE_AUDIO_CODECS.has(audio.codec));
31450
+ if (audioNeedsFallback) {
31451
+ if (NATIVE_VIDEO_CODECS.has(video.codec) && webCodecsAvailable()) {
31452
+ return {
31453
+ class: "HYBRID_CANDIDATE",
31454
+ strategy: "hybrid",
31455
+ reason: `video "${video.codec}" is hardware-decodable via WebCodecs; audio "${audio.codec}" decoded in software by libav`,
31456
+ fallbackChain: ["fallback"]
31457
+ };
31458
+ }
31361
31459
  return {
31362
31460
  class: "FALLBACK_REQUIRED",
31363
31461
  strategy: "fallback",
@@ -31642,14 +31740,23 @@ function sourceToVideoUrl(source) {
31642
31740
  }
31643
31741
 
31644
31742
  // src/strategies/remux/mse.ts
31743
+ init_errors();
31645
31744
  var MseSink = class {
31646
31745
  constructor(options) {
31647
31746
  this.options = options;
31648
31747
  if (typeof MediaSource === "undefined") {
31649
- throw new Error("MSE not supported in this environment");
31748
+ throw new AvbridgeError(
31749
+ ERR_MSE_NOT_SUPPORTED,
31750
+ "MediaSource Extensions (MSE) are not supported in this environment.",
31751
+ "MSE is required for the remux strategy. Use a browser that supports MSE, or try the fallback strategy."
31752
+ );
31650
31753
  }
31651
31754
  if (!MediaSource.isTypeSupported(options.mime)) {
31652
- throw new Error(`MSE does not support MIME "${options.mime}" \u2014 cannot remux`);
31755
+ throw new AvbridgeError(
31756
+ ERR_MSE_CODEC_NOT_SUPPORTED,
31757
+ `This browser's MSE does not support "${options.mime}".`,
31758
+ "The codec combination can't be played via remux in this browser. The player will try the next strategy automatically."
31759
+ );
31653
31760
  }
31654
31761
  this.mediaSource = new MediaSource();
31655
31762
  this.objectUrl = URL.createObjectURL(this.mediaSource);
@@ -31978,6 +32085,10 @@ async function createRemuxPipeline(ctx, video) {
31978
32085
  console.error("[avbridge] remux pipeline reseek failed:", err);
31979
32086
  });
31980
32087
  },
32088
+ setAutoPlay(autoPlay) {
32089
+ pendingAutoPlay = autoPlay;
32090
+ if (sink) sink.setPlayOnSeek(autoPlay);
32091
+ },
31981
32092
  async destroy() {
31982
32093
  destroyed = true;
31983
32094
  pumpToken++;
@@ -32018,7 +32129,11 @@ async function createRemuxSession(context, video) {
32018
32129
  await pipeline.start(video.currentTime || 0, true);
32019
32130
  return;
32020
32131
  }
32021
- await video.play();
32132
+ pipeline.setAutoPlay(true);
32133
+ try {
32134
+ await video.play();
32135
+ } catch {
32136
+ }
32022
32137
  },
32023
32138
  pause() {
32024
32139
  wantPlay = false;
@@ -32057,6 +32172,10 @@ async function createRemuxSession(context, video) {
32057
32172
  }
32058
32173
 
32059
32174
  // src/strategies/fallback/video-renderer.ts
32175
+ function isDebug() {
32176
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
32177
+ }
32178
+ var lastDebugLog = 0;
32060
32179
  var VideoRenderer = class {
32061
32180
  constructor(target, clock, fps = 30) {
32062
32181
  this.target = target;
@@ -32066,7 +32185,7 @@ var VideoRenderer = class {
32066
32185
  this.resolveFirstFrame = resolve;
32067
32186
  });
32068
32187
  this.canvas = document.createElement("canvas");
32069
- this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;";
32188
+ this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
32070
32189
  const parent = target.parentElement ?? target.parentNode;
32071
32190
  if (parent && parent instanceof HTMLElement) {
32072
32191
  if (getComputedStyle(parent).position === "static") {
@@ -32103,6 +32222,20 @@ var VideoRenderer = class {
32103
32222
  lastPaintWall = 0;
32104
32223
  /** Minimum ms between paints — paces video at roughly source fps. */
32105
32224
  paintIntervalMs;
32225
+ /** Cumulative count of frames skipped because all PTS are in the future. */
32226
+ ticksWaiting = 0;
32227
+ /** Cumulative count of ticks where PTS mode painted a frame. */
32228
+ ticksPainted = 0;
32229
+ /**
32230
+ * Calibration offset (microseconds) between video PTS and audio clock.
32231
+ * Video PTS and AudioContext.currentTime can drift ~0.1% relative to
32232
+ * each other (different clock domains). Over 45 minutes that's 2.6s.
32233
+ * We measure the offset on the first painted frame and update it
32234
+ * periodically so the PTS comparison stays calibrated.
32235
+ */
32236
+ ptsCalibrationUs = 0;
32237
+ ptsCalibrated = false;
32238
+ lastCalibrationWall = 0;
32106
32239
  /** Resolves once the first decoded frame has been enqueued. */
32107
32240
  firstFrameReady;
32108
32241
  resolveFirstFrame;
@@ -32151,21 +32284,81 @@ var VideoRenderer = class {
32151
32284
  }
32152
32285
  return;
32153
32286
  }
32154
- const wallNow = performance.now();
32155
- if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
32156
- if (this.queue.length === 0) return;
32157
- if (this.framesPainted > 0 && this.framesPainted % 30 === 0) {
32158
- const audioNowUs = this.clock.now() * 1e6;
32159
- const headTs = this.queue[0].timestamp ?? 0;
32160
- const driftUs = headTs - audioNowUs;
32161
- if (driftUs < -15e4) {
32162
- this.queue.shift()?.close();
32163
- this.framesDroppedLate++;
32164
- if (this.queue.length === 0) return;
32165
- } else if (driftUs > 15e4) {
32287
+ const rawAudioNowUs = this.clock.now() * 1e6;
32288
+ const headTs = this.queue[0].timestamp ?? 0;
32289
+ const hasPts = headTs > 0 || this.queue.length > 1;
32290
+ if (hasPts) {
32291
+ const wallNow2 = performance.now();
32292
+ if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
32293
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
32294
+ this.ptsCalibrated = true;
32295
+ this.lastCalibrationWall = wallNow2;
32296
+ }
32297
+ const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
32298
+ const frameDurationUs = this.paintIntervalMs * 1e3;
32299
+ const deadlineUs = audioNowUs + frameDurationUs;
32300
+ let bestIdx = -1;
32301
+ for (let i = 0; i < this.queue.length; i++) {
32302
+ const ts = this.queue[i].timestamp ?? 0;
32303
+ if (ts <= deadlineUs) {
32304
+ bestIdx = i;
32305
+ } else {
32306
+ break;
32307
+ }
32308
+ }
32309
+ if (bestIdx < 0) {
32310
+ this.ticksWaiting++;
32311
+ if (isDebug()) {
32312
+ const now = performance.now();
32313
+ if (now - lastDebugLog > 1e3) {
32314
+ const headPtsMs = (headTs / 1e3).toFixed(1);
32315
+ const audioMs = (audioNowUs / 1e3).toFixed(1);
32316
+ const rawDriftMs = ((headTs - rawAudioNowUs) / 1e3).toFixed(1);
32317
+ const calibMs = (this.ptsCalibrationUs / 1e3).toFixed(1);
32318
+ console.log(
32319
+ `[avbridge:renderer] WAIT q=${this.queue.length} headPTS=${headPtsMs}ms calibAudio=${audioMs}ms rawDrift=${rawDriftMs}ms calib=${calibMs}ms painted=${this.framesPainted} dropped=${this.framesDroppedLate}`
32320
+ );
32321
+ lastDebugLog = now;
32322
+ }
32323
+ }
32166
32324
  return;
32167
32325
  }
32326
+ const dropThresholdUs = audioNowUs - frameDurationUs * 2;
32327
+ let dropped = 0;
32328
+ while (bestIdx > 0) {
32329
+ const ts = this.queue[0].timestamp ?? 0;
32330
+ if (ts < dropThresholdUs) {
32331
+ this.queue.shift()?.close();
32332
+ this.framesDroppedLate++;
32333
+ bestIdx--;
32334
+ dropped++;
32335
+ } else {
32336
+ break;
32337
+ }
32338
+ }
32339
+ this.ticksPainted++;
32340
+ if (isDebug()) {
32341
+ const now = performance.now();
32342
+ if (now - lastDebugLog > 1e3) {
32343
+ const paintedTs = this.queue[0]?.timestamp ?? 0;
32344
+ const audioMs = (audioNowUs / 1e3).toFixed(1);
32345
+ const ptsMs = (paintedTs / 1e3).toFixed(1);
32346
+ const rawDriftMs = ((paintedTs - rawAudioNowUs) / 1e3).toFixed(1);
32347
+ const calibMs = (this.ptsCalibrationUs / 1e3).toFixed(1);
32348
+ console.log(
32349
+ `[avbridge:renderer] PAINT q=${this.queue.length} calibAudio=${audioMs}ms nextPTS=${ptsMs}ms rawDrift=${rawDriftMs}ms calib=${calibMs}ms dropped=${dropped} total_drops=${this.framesDroppedLate} painted=${this.framesPainted}`
32350
+ );
32351
+ lastDebugLog = now;
32352
+ }
32353
+ }
32354
+ const frame2 = this.queue.shift();
32355
+ this.paint(frame2);
32356
+ frame2.close();
32357
+ this.lastPaintWall = performance.now();
32358
+ return;
32168
32359
  }
32360
+ const wallNow = performance.now();
32361
+ if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
32169
32362
  const frame = this.queue.shift();
32170
32363
  this.paint(frame);
32171
32364
  frame.close();
@@ -32187,8 +32380,13 @@ var VideoRenderer = class {
32187
32380
  }
32188
32381
  /** Discard all queued frames. Used by seek to drop stale buffers. */
32189
32382
  flush() {
32383
+ const count = this.queue.length;
32190
32384
  while (this.queue.length > 0) this.queue.shift()?.close();
32191
32385
  this.prerolled = false;
32386
+ this.ptsCalibrated = false;
32387
+ if (isDebug() && count > 0) {
32388
+ console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
32389
+ }
32192
32390
  }
32193
32391
  stats() {
32194
32392
  return {
@@ -32233,11 +32431,38 @@ var AudioOutput = class {
32233
32431
  pendingQueue = [];
32234
32432
  framesScheduled = 0;
32235
32433
  destroyed = false;
32434
+ /** User-set volume (0..1). Applied to the gain node. */
32435
+ _volume = 1;
32436
+ /** User-set muted flag. When true, gain is forced to 0. */
32437
+ _muted = false;
32236
32438
  constructor() {
32237
32439
  this.ctx = new AudioContext();
32238
32440
  this.gain = this.ctx.createGain();
32239
32441
  this.gain.connect(this.ctx.destination);
32240
32442
  }
32443
+ /** Set volume (0..1). Applied immediately to the gain node. */
32444
+ setVolume(v) {
32445
+ this._volume = Math.max(0, Math.min(1, v));
32446
+ this.applyGain();
32447
+ }
32448
+ getVolume() {
32449
+ return this._volume;
32450
+ }
32451
+ /** Set muted. When true, output is silenced regardless of volume. */
32452
+ setMuted(m) {
32453
+ this._muted = m;
32454
+ this.applyGain();
32455
+ }
32456
+ getMuted() {
32457
+ return this._muted;
32458
+ }
32459
+ applyGain() {
32460
+ const target = this._muted ? 0 : this._volume;
32461
+ try {
32462
+ this.gain.gain.value = target;
32463
+ } catch {
32464
+ }
32465
+ }
32241
32466
  /**
32242
32467
  * Switch into wall-clock fallback mode. Called by the decoder when no
32243
32468
  * audio decoder could be initialized for the source. Once set, this
@@ -32308,9 +32533,13 @@ var AudioOutput = class {
32308
32533
  const node3 = this.ctx.createBufferSource();
32309
32534
  node3.buffer = buffer;
32310
32535
  node3.connect(this.gain);
32311
- const ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
32312
- const safeStart = Math.max(ctxStart, this.ctx.currentTime);
32313
- node3.start(safeStart);
32536
+ let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
32537
+ if (ctxStart < this.ctx.currentTime) {
32538
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
32539
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
32540
+ ctxStart = this.ctx.currentTime;
32541
+ }
32542
+ node3.start(ctxStart);
32314
32543
  this.mediaTimeOfNext += frameCount / sampleRate;
32315
32544
  this.framesScheduled++;
32316
32545
  }
@@ -32383,6 +32612,7 @@ var AudioOutput = class {
32383
32612
  }
32384
32613
  this.gain = this.ctx.createGain();
32385
32614
  this.gain.connect(this.ctx.destination);
32615
+ this.applyGain();
32386
32616
  this.pendingQueue = [];
32387
32617
  this.mediaTimeOfAnchor = newMediaTime;
32388
32618
  this.mediaTimeOfNext = newMediaTime;
@@ -32411,27 +32641,19 @@ var AudioOutput = class {
32411
32641
 
32412
32642
  // src/strategies/hybrid/decoder.ts
32413
32643
  init_libav_loader();
32644
+ init_debug();
32414
32645
 
32415
32646
  // src/strategies/fallback/variant-routing.ts
32416
32647
  var LEGACY_CONTAINERS = /* @__PURE__ */ new Set(["avi", "asf", "flv"]);
32417
- var LEGACY_VIDEO_CODECS = /* @__PURE__ */ new Set([
32418
- "wmv3",
32419
- "vc1",
32420
- "mpeg4",
32421
- // MPEG-4 Part 2 / DivX / Xvid
32422
- "rv40",
32423
- "mpeg2",
32424
- "mpeg1",
32425
- "theora"
32426
- ]);
32427
- var LEGACY_AUDIO_CODECS = /* @__PURE__ */ new Set(["wmav2", "wmapro", "ac3", "eac3"]);
32648
+ var WEBCODECS_AUDIO = /* @__PURE__ */ new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
32649
+ var WEBCODECS_VIDEO = /* @__PURE__ */ new Set(["h264", "h265", "vp8", "vp9", "av1"]);
32428
32650
  function pickLibavVariant(ctx) {
32429
32651
  if (LEGACY_CONTAINERS.has(ctx.container)) return "avbridge";
32430
32652
  for (const v of ctx.videoTracks) {
32431
- if (LEGACY_VIDEO_CODECS.has(v.codec)) return "avbridge";
32653
+ if (!WEBCODECS_VIDEO.has(v.codec)) return "avbridge";
32432
32654
  }
32433
32655
  for (const a of ctx.audioTracks) {
32434
- if (LEGACY_AUDIO_CODECS.has(a.codec)) return "avbridge";
32656
+ if (!WEBCODECS_AUDIO.has(a.codec)) return "avbridge";
32435
32657
  }
32436
32658
  return "webcodecs";
32437
32659
  }
@@ -32442,7 +32664,7 @@ async function startHybridDecoder(opts) {
32442
32664
  const libav = await loadLibav(variant);
32443
32665
  const bridge = await loadBridge();
32444
32666
  const { prepareLibavInput: prepareLibavInput2 } = await Promise.resolve().then(() => (init_libav_http_reader(), libav_http_reader_exports));
32445
- const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source);
32667
+ const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source, opts.transport);
32446
32668
  const readPkt = await libav.av_packet_alloc();
32447
32669
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
32448
32670
  const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
@@ -32513,6 +32735,56 @@ async function startHybridDecoder(opts) {
32513
32735
  });
32514
32736
  throw new Error("hybrid decoder: could not initialize any decoders");
32515
32737
  }
32738
+ let bsfCtx = null;
32739
+ let bsfPkt = null;
32740
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
32741
+ try {
32742
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
32743
+ if (bsfCtx != null && bsfCtx >= 0) {
32744
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
32745
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
32746
+ await libav.av_bsf_init(bsfCtx);
32747
+ bsfPkt = await libav.av_packet_alloc();
32748
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
32749
+ } else {
32750
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
32751
+ bsfCtx = null;
32752
+ }
32753
+ } catch (err) {
32754
+ console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
32755
+ bsfCtx = null;
32756
+ bsfPkt = null;
32757
+ }
32758
+ }
32759
+ async function applyBSF(packets) {
32760
+ if (!bsfCtx || !bsfPkt) return packets;
32761
+ const out = [];
32762
+ for (const pkt of packets) {
32763
+ await libav.ff_copyin_packet(bsfPkt, pkt);
32764
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
32765
+ if (sendErr < 0) {
32766
+ out.push(pkt);
32767
+ continue;
32768
+ }
32769
+ while (true) {
32770
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
32771
+ if (recvErr < 0) break;
32772
+ out.push(await libav.ff_copyout_packet(bsfPkt));
32773
+ }
32774
+ }
32775
+ return out;
32776
+ }
32777
+ async function flushBSF() {
32778
+ if (!bsfCtx || !bsfPkt) return;
32779
+ try {
32780
+ await libav.av_bsf_send_packet(bsfCtx, 0);
32781
+ while (true) {
32782
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
32783
+ if (err < 0) break;
32784
+ }
32785
+ } catch {
32786
+ }
32787
+ }
32516
32788
  let destroyed = false;
32517
32789
  let pumpToken = 0;
32518
32790
  let pumpRunning = null;
@@ -32540,8 +32812,15 @@ async function startHybridDecoder(opts) {
32540
32812
  if (myToken !== pumpToken || destroyed) return;
32541
32813
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
32542
32814
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
32815
+ if (audioDec && audioPackets && audioPackets.length > 0) {
32816
+ await decodeAudioBatch(audioPackets, myToken);
32817
+ }
32818
+ if (myToken !== pumpToken || destroyed) return;
32819
+ await new Promise((r) => setTimeout(r, 0));
32820
+ if (myToken !== pumpToken || destroyed) return;
32543
32821
  if (videoDecoder && videoPackets && videoPackets.length > 0) {
32544
- for (const pkt of videoPackets) {
32822
+ const processed = await applyBSF(videoPackets);
32823
+ for (const pkt of processed) {
32545
32824
  if (myToken !== pumpToken || destroyed) return;
32546
32825
  sanitizePacketTimestamp(pkt, () => {
32547
32826
  const ts = syntheticVideoUs;
@@ -32561,9 +32840,6 @@ async function startHybridDecoder(opts) {
32561
32840
  }
32562
32841
  }
32563
32842
  }
32564
- if (audioDec && audioPackets && audioPackets.length > 0) {
32565
- await decodeAudioBatch(audioPackets, myToken);
32566
- }
32567
32843
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
32568
32844
  while (!destroyed && myToken === pumpToken && (videoDecoder && videoDecoder.decodeQueueSize > 10 || opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
32569
32845
  await new Promise((r) => setTimeout(r, 50));
@@ -32586,20 +32862,43 @@ async function startHybridDecoder(opts) {
32586
32862
  }
32587
32863
  async function decodeAudioBatch(pkts, myToken, flush = false) {
32588
32864
  if (!audioDec || destroyed || myToken !== pumpToken) return;
32589
- let frames;
32590
- try {
32591
- frames = await libav.ff_decode_multi(
32592
- audioDec.c,
32593
- audioDec.pkt,
32594
- audioDec.frame,
32595
- pkts,
32596
- flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true }
32597
- );
32598
- } catch (err) {
32599
- console.error("[avbridge] hybrid audio decode failed:", err);
32600
- return;
32865
+ const AUDIO_SUB_BATCH = 4;
32866
+ let allFrames = [];
32867
+ for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
32868
+ if (myToken !== pumpToken || destroyed) return;
32869
+ const slice = pkts.slice(i, i + AUDIO_SUB_BATCH);
32870
+ const isLast = i + AUDIO_SUB_BATCH >= pkts.length;
32871
+ try {
32872
+ const frames2 = await libav.ff_decode_multi(
32873
+ audioDec.c,
32874
+ audioDec.pkt,
32875
+ audioDec.frame,
32876
+ slice,
32877
+ isLast && flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true }
32878
+ );
32879
+ allFrames = allFrames.concat(frames2);
32880
+ } catch (err) {
32881
+ console.error("[avbridge] hybrid audio decode failed:", err);
32882
+ return;
32883
+ }
32884
+ if (!isLast) await new Promise((r) => setTimeout(r, 0));
32885
+ }
32886
+ if (pkts.length === 0 && flush) {
32887
+ try {
32888
+ allFrames = await libav.ff_decode_multi(
32889
+ audioDec.c,
32890
+ audioDec.pkt,
32891
+ audioDec.frame,
32892
+ [],
32893
+ { fin: true, ignoreErrors: true }
32894
+ );
32895
+ } catch (err) {
32896
+ console.error("[avbridge] hybrid audio flush failed:", err);
32897
+ return;
32898
+ }
32601
32899
  }
32602
32900
  if (myToken !== pumpToken || destroyed) return;
32901
+ const frames = allFrames;
32603
32902
  for (const f of frames) {
32604
32903
  if (myToken !== pumpToken || destroyed) return;
32605
32904
  sanitizeFrameTimestamp(
@@ -32636,6 +32935,14 @@ async function startHybridDecoder(opts) {
32636
32935
  await pumpRunning;
32637
32936
  } catch {
32638
32937
  }
32938
+ try {
32939
+ if (bsfCtx) await libav.av_bsf_free(bsfCtx);
32940
+ } catch {
32941
+ }
32942
+ try {
32943
+ if (bsfPkt) await libav.av_packet_free?.(bsfPkt);
32944
+ } catch {
32945
+ }
32639
32946
  try {
32640
32947
  if (videoDecoder && videoDecoder.state !== "closed") videoDecoder.close();
32641
32948
  } catch {
@@ -32689,6 +32996,7 @@ async function startHybridDecoder(opts) {
32689
32996
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
32690
32997
  } catch {
32691
32998
  }
32999
+ await flushBSF();
32692
33000
  syntheticVideoUs = Math.round(timeSec * 1e6);
32693
33001
  syntheticAudioUs = Math.round(timeSec * 1e6);
32694
33002
  pumpRunning = pumpLoop(newToken).catch(
@@ -32702,6 +33010,7 @@ async function startHybridDecoder(opts) {
32702
33010
  videoFramesDecoded,
32703
33011
  videoChunksFed,
32704
33012
  audioFramesDecoded,
33013
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
32705
33014
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
32706
33015
  // Confirmed transport info — see fallback decoder for the pattern.
32707
33016
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -32878,7 +33187,7 @@ async function loadBridge() {
32878
33187
  // src/strategies/hybrid/index.ts
32879
33188
  var READY_AUDIO_BUFFER_SECONDS = 0.3;
32880
33189
  var READY_TIMEOUT_SECONDS = 10;
32881
- async function createHybridSession(ctx, target) {
33190
+ async function createHybridSession(ctx, target, transport) {
32882
33191
  const { normalizeSource: normalizeSource2 } = await Promise.resolve().then(() => (init_source(), source_exports));
32883
33192
  const source = await normalizeSource2(ctx.source);
32884
33193
  const fps = ctx.videoTracks[0]?.fps ?? 30;
@@ -32891,7 +33200,8 @@ async function createHybridSession(ctx, target) {
32891
33200
  filename: ctx.name ?? "input.bin",
32892
33201
  context: ctx,
32893
33202
  renderer,
32894
- audio
33203
+ audio,
33204
+ transport
32895
33205
  });
32896
33206
  } catch (err) {
32897
33207
  audio.destroy();
@@ -32905,6 +33215,26 @@ async function createHybridSession(ctx, target) {
32905
33215
  void doSeek(v);
32906
33216
  }
32907
33217
  });
33218
+ Object.defineProperty(target, "paused", {
33219
+ configurable: true,
33220
+ get: () => !audio.isPlaying()
33221
+ });
33222
+ Object.defineProperty(target, "volume", {
33223
+ configurable: true,
33224
+ get: () => audio.getVolume(),
33225
+ set: (v) => {
33226
+ audio.setVolume(v);
33227
+ target.dispatchEvent(new Event("volumechange"));
33228
+ }
33229
+ });
33230
+ Object.defineProperty(target, "muted", {
33231
+ configurable: true,
33232
+ get: () => audio.getMuted(),
33233
+ set: (m) => {
33234
+ audio.setMuted(m);
33235
+ target.dispatchEvent(new Event("volumechange"));
33236
+ }
33237
+ });
32908
33238
  if (ctx.duration && Number.isFinite(ctx.duration)) {
32909
33239
  Object.defineProperty(target, "duration", {
32910
33240
  configurable: true,
@@ -32944,10 +33274,13 @@ async function createHybridSession(ctx, target) {
32944
33274
  if (!audio.isPlaying()) {
32945
33275
  await waitForBuffer();
32946
33276
  await audio.start();
33277
+ target.dispatchEvent(new Event("play"));
33278
+ target.dispatchEvent(new Event("playing"));
32947
33279
  }
32948
33280
  },
32949
33281
  pause() {
32950
33282
  void audio.pause();
33283
+ target.dispatchEvent(new Event("pause"));
32951
33284
  },
32952
33285
  async seek(time) {
32953
33286
  await doSeek(time);
@@ -32969,6 +33302,9 @@ async function createHybridSession(ctx, target) {
32969
33302
  try {
32970
33303
  delete target.currentTime;
32971
33304
  delete target.duration;
33305
+ delete target.paused;
33306
+ delete target.volume;
33307
+ delete target.muted;
32972
33308
  } catch {
32973
33309
  }
32974
33310
  },
@@ -32980,12 +33316,13 @@ async function createHybridSession(ctx, target) {
32980
33316
 
32981
33317
  // src/strategies/fallback/decoder.ts
32982
33318
  init_libav_loader();
33319
+ init_debug();
32983
33320
  async function startDecoder(opts) {
32984
33321
  const variant = pickLibavVariant(opts.context);
32985
33322
  const libav = await loadLibav(variant);
32986
33323
  const bridge = await loadBridge2();
32987
33324
  const { prepareLibavInput: prepareLibavInput2 } = await Promise.resolve().then(() => (init_libav_http_reader(), libav_http_reader_exports));
32988
- const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source);
33325
+ const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source, opts.transport);
32989
33326
  const readPkt = await libav.av_packet_alloc();
32990
33327
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
32991
33328
  const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
@@ -33041,6 +33378,56 @@ async function startDecoder(opts) {
33041
33378
  `fallback decoder: could not initialize any libav decoders (${codecs}).${hint}`
33042
33379
  );
33043
33380
  }
33381
+ let bsfCtx = null;
33382
+ let bsfPkt = null;
33383
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
33384
+ try {
33385
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
33386
+ if (bsfCtx != null && bsfCtx >= 0) {
33387
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
33388
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
33389
+ await libav.av_bsf_init(bsfCtx);
33390
+ bsfPkt = await libav.av_packet_alloc();
33391
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
33392
+ } else {
33393
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
33394
+ bsfCtx = null;
33395
+ }
33396
+ } catch (err) {
33397
+ console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
33398
+ bsfCtx = null;
33399
+ bsfPkt = null;
33400
+ }
33401
+ }
33402
+ async function applyBSF(packets) {
33403
+ if (!bsfCtx || !bsfPkt) return packets;
33404
+ const out = [];
33405
+ for (const pkt of packets) {
33406
+ await libav.ff_copyin_packet(bsfPkt, pkt);
33407
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
33408
+ if (sendErr < 0) {
33409
+ out.push(pkt);
33410
+ continue;
33411
+ }
33412
+ while (true) {
33413
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33414
+ if (recvErr < 0) break;
33415
+ out.push(await libav.ff_copyout_packet(bsfPkt));
33416
+ }
33417
+ }
33418
+ return out;
33419
+ }
33420
+ async function flushBSF() {
33421
+ if (!bsfCtx || !bsfPkt) return;
33422
+ try {
33423
+ await libav.av_bsf_send_packet(bsfCtx, 0);
33424
+ while (true) {
33425
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33426
+ if (err < 0) break;
33427
+ }
33428
+ } catch {
33429
+ }
33430
+ }
33044
33431
  let destroyed = false;
33045
33432
  let pumpToken = 0;
33046
33433
  let pumpRunning = null;
@@ -33049,7 +33436,8 @@ async function startDecoder(opts) {
33049
33436
  let audioFramesDecoded = 0;
33050
33437
  let watchdogFirstFrameMs = 0;
33051
33438
  let watchdogSlowSinceMs = 0;
33052
- let watchdogWarned = false;
33439
+ let watchdogSlowWarned = false;
33440
+ let watchdogOverflowWarned = false;
33053
33441
  let syntheticVideoUs = 0;
33054
33442
  let syntheticAudioUs = 0;
33055
33443
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -33061,7 +33449,7 @@ async function startDecoder(opts) {
33061
33449
  let packets;
33062
33450
  try {
33063
33451
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
33064
- limit: 64 * 1024
33452
+ limit: 16 * 1024
33065
33453
  });
33066
33454
  } catch (err) {
33067
33455
  console.error("[avbridge] ff_read_frame_multi failed:", err);
@@ -33070,26 +33458,27 @@ async function startDecoder(opts) {
33070
33458
  if (myToken !== pumpToken || destroyed) return;
33071
33459
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
33072
33460
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
33073
- if (videoDec && videoPackets && videoPackets.length > 0) {
33074
- await decodeVideoBatch(videoPackets, myToken);
33075
- }
33076
- if (myToken !== pumpToken || destroyed) return;
33077
33461
  if (audioDec && audioPackets && audioPackets.length > 0) {
33078
33462
  await decodeAudioBatch(audioPackets, myToken);
33079
33463
  }
33464
+ if (myToken !== pumpToken || destroyed) return;
33465
+ if (videoDec && videoPackets && videoPackets.length > 0) {
33466
+ const processed = await applyBSF(videoPackets);
33467
+ await decodeVideoBatch(processed, myToken);
33468
+ }
33080
33469
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
33081
33470
  if (videoFramesDecoded > 0) {
33082
33471
  if (watchdogFirstFrameMs === 0) {
33083
33472
  watchdogFirstFrameMs = performance.now();
33084
33473
  }
33085
33474
  const elapsedSinceFirst = (performance.now() - watchdogFirstFrameMs) / 1e3;
33086
- if (elapsedSinceFirst > 1 && !watchdogWarned) {
33475
+ if (elapsedSinceFirst > 1 && !watchdogSlowWarned) {
33087
33476
  const expectedFrames = elapsedSinceFirst * videoFps;
33088
33477
  const ratio = videoFramesDecoded / expectedFrames;
33089
33478
  if (ratio < 0.6) {
33090
33479
  if (watchdogSlowSinceMs === 0) watchdogSlowSinceMs = performance.now();
33091
33480
  if ((performance.now() - watchdogSlowSinceMs) / 1e3 > 5) {
33092
- watchdogWarned = true;
33481
+ watchdogSlowWarned = true;
33093
33482
  console.warn(
33094
33483
  "[avbridge:decode-rate]",
33095
33484
  `decoder is running slower than realtime: ${videoFramesDecoded} frames in ${elapsedSinceFirst.toFixed(1)}s (${(videoFramesDecoded / elapsedSinceFirst).toFixed(1)} fps vs ${videoFps} fps source \u2014 ${(ratio * 100).toFixed(0)}% of realtime). Playback will stutter. Typical causes: software decode of a codec with no WebCodecs support (rv40, mpeg4 @ 720p+, wmv3), or a resolution too large for single-threaded WASM to keep up with.`
@@ -33099,6 +33488,17 @@ async function startDecoder(opts) {
33099
33488
  watchdogSlowSinceMs = 0;
33100
33489
  }
33101
33490
  }
33491
+ if (!watchdogOverflowWarned && videoFramesDecoded > 100) {
33492
+ const rendererStats = opts.renderer.stats();
33493
+ const overflow = rendererStats.framesDroppedOverflow ?? 0;
33494
+ if (overflow / videoFramesDecoded > 0.1) {
33495
+ watchdogOverflowWarned = true;
33496
+ console.warn(
33497
+ "[avbridge:overflow-drop]",
33498
+ `renderer is dropping ${overflow}/${videoFramesDecoded} frames (${(overflow / videoFramesDecoded * 100).toFixed(0)}%) because the decoder is producing bursts faster than the canvas can drain. Symptom: choppy playback despite decoder keeping up on average. Fix would be smaller read batches in the pump loop or a lower queueHighWater cap \u2014 see src/strategies/fallback/decoder.ts.`
33499
+ );
33500
+ }
33501
+ }
33102
33502
  }
33103
33503
  while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
33104
33504
  await new Promise((r) => setTimeout(r, 50));
@@ -33210,6 +33610,14 @@ async function startDecoder(opts) {
33210
33610
  await pumpRunning;
33211
33611
  } catch {
33212
33612
  }
33613
+ try {
33614
+ if (bsfCtx) await libav.av_bsf_free(bsfCtx);
33615
+ } catch {
33616
+ }
33617
+ try {
33618
+ if (bsfPkt) await libav.av_packet_free?.(bsfPkt);
33619
+ } catch {
33620
+ }
33213
33621
  try {
33214
33622
  if (videoDec) await libav.ff_free_decoder?.(videoDec.c, videoDec.pkt, videoDec.frame);
33215
33623
  } catch {
@@ -33261,6 +33669,7 @@ async function startDecoder(opts) {
33261
33669
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
33262
33670
  } catch {
33263
33671
  }
33672
+ await flushBSF();
33264
33673
  syntheticVideoUs = Math.round(timeSec * 1e6);
33265
33674
  syntheticAudioUs = Math.round(timeSec * 1e6);
33266
33675
  pumpRunning = pumpLoop(newToken).catch(
@@ -33273,6 +33682,7 @@ async function startDecoder(opts) {
33273
33682
  packetsRead,
33274
33683
  videoFramesDecoded,
33275
33684
  audioFramesDecoded,
33685
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
33276
33686
  // Confirmed transport info: once prepareLibavInput returns
33277
33687
  // successfully, we *know* whether the source is http-range (probe
33278
33688
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -33429,7 +33839,7 @@ async function loadBridge2() {
33429
33839
  init_debug();
33430
33840
  var READY_AUDIO_BUFFER_SECONDS2 = 0.04;
33431
33841
  var READY_TIMEOUT_SECONDS2 = 3;
33432
- async function createFallbackSession(ctx, target) {
33842
+ async function createFallbackSession(ctx, target, transport) {
33433
33843
  const { normalizeSource: normalizeSource2 } = await Promise.resolve().then(() => (init_source(), source_exports));
33434
33844
  const source = await normalizeSource2(ctx.source);
33435
33845
  const fps = ctx.videoTracks[0]?.fps ?? 30;
@@ -33442,7 +33852,8 @@ async function createFallbackSession(ctx, target) {
33442
33852
  filename: ctx.name ?? "input.bin",
33443
33853
  context: ctx,
33444
33854
  renderer,
33445
- audio
33855
+ audio,
33856
+ transport
33446
33857
  });
33447
33858
  } catch (err) {
33448
33859
  audio.destroy();
@@ -33456,6 +33867,26 @@ async function createFallbackSession(ctx, target) {
33456
33867
  void doSeek(v);
33457
33868
  }
33458
33869
  });
33870
+ Object.defineProperty(target, "paused", {
33871
+ configurable: true,
33872
+ get: () => !audio.isPlaying()
33873
+ });
33874
+ Object.defineProperty(target, "volume", {
33875
+ configurable: true,
33876
+ get: () => audio.getVolume(),
33877
+ set: (v) => {
33878
+ audio.setVolume(v);
33879
+ target.dispatchEvent(new Event("volumechange"));
33880
+ }
33881
+ });
33882
+ Object.defineProperty(target, "muted", {
33883
+ configurable: true,
33884
+ get: () => audio.getMuted(),
33885
+ set: (m) => {
33886
+ audio.setMuted(m);
33887
+ target.dispatchEvent(new Event("volumechange"));
33888
+ }
33889
+ });
33459
33890
  if (ctx.duration && Number.isFinite(ctx.duration)) {
33460
33891
  Object.defineProperty(target, "duration", {
33461
33892
  configurable: true,
@@ -33464,25 +33895,35 @@ async function createFallbackSession(ctx, target) {
33464
33895
  }
33465
33896
  async function waitForBuffer() {
33466
33897
  const start = performance.now();
33898
+ let firstFrameAtMs = 0;
33467
33899
  dbg.info(
33468
33900
  "cold-start",
33469
- `gate entry: need audio >= ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms + 1 frame`
33901
+ `gate entry: want audio \u2265 ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms + 1 frame`
33470
33902
  );
33471
33903
  while (true) {
33472
33904
  const audioAhead = audio.isNoAudio() ? Infinity : audio.bufferAhead();
33473
33905
  const audioReady = audio.isNoAudio() || audioAhead >= READY_AUDIO_BUFFER_SECONDS2;
33474
33906
  const hasFrames = renderer.hasFrames();
33907
+ const nowMs = performance.now();
33908
+ if (hasFrames && firstFrameAtMs === 0) firstFrameAtMs = nowMs;
33475
33909
  if (audioReady && hasFrames) {
33476
33910
  dbg.info(
33477
33911
  "cold-start",
33478
- `gate satisfied in ${(performance.now() - start).toFixed(0)}ms (audio=${(audioAhead * 1e3).toFixed(0)}ms, frames=${renderer.queueDepth()})`
33912
+ `gate satisfied in ${(nowMs - start).toFixed(0)}ms (audio=${(audioAhead * 1e3).toFixed(0)}ms, frames=${renderer.queueDepth()})`
33479
33913
  );
33480
33914
  return;
33481
33915
  }
33482
- if ((performance.now() - start) / 1e3 > READY_TIMEOUT_SECONDS2) {
33916
+ if (hasFrames && firstFrameAtMs > 0 && nowMs - firstFrameAtMs >= 500) {
33917
+ dbg.info(
33918
+ "cold-start",
33919
+ `gate released on video-only grace at ${(nowMs - start).toFixed(0)}ms (frames=${renderer.queueDepth()}, audio=${(audioAhead * 1e3).toFixed(0)}ms \u2014 demuxer hasn't delivered audio packets yet, starting anyway and letting the audio scheduler catch up at its media-time anchor)`
33920
+ );
33921
+ return;
33922
+ }
33923
+ if ((nowMs - start) / 1e3 > READY_TIMEOUT_SECONDS2) {
33483
33924
  dbg.diag(
33484
33925
  "cold-start",
33485
- `gate TIMEOUT after ${READY_TIMEOUT_SECONDS2}s \u2014 audio=${(audioAhead * 1e3).toFixed(0)}ms (needed ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms), frames=${renderer.queueDepth()} (needed \u22651). Software decoder is producing output slower than realtime \u2014 playback will stutter. Check getDiagnostics().runtime for the decode rate.`
33926
+ `gate TIMEOUT after ${READY_TIMEOUT_SECONDS2}s \u2014 audio=${(audioAhead * 1e3).toFixed(0)}ms (needed ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms), frames=${renderer.queueDepth()} (needed \u22651). Decoder produced nothing in ${READY_TIMEOUT_SECONDS2}s \u2014 either a corrupt source, a missing codec, or WASM is catastrophically slow on this file. Check getDiagnostics().runtime for decode counters.`
33486
33927
  );
33487
33928
  return;
33488
33929
  }
@@ -33509,10 +33950,13 @@ async function createFallbackSession(ctx, target) {
33509
33950
  if (!audio.isPlaying()) {
33510
33951
  await waitForBuffer();
33511
33952
  await audio.start();
33953
+ target.dispatchEvent(new Event("play"));
33954
+ target.dispatchEvent(new Event("playing"));
33512
33955
  }
33513
33956
  },
33514
33957
  pause() {
33515
33958
  void audio.pause();
33959
+ target.dispatchEvent(new Event("pause"));
33516
33960
  },
33517
33961
  async seek(time) {
33518
33962
  await doSeek(time);
@@ -33531,6 +33975,9 @@ async function createFallbackSession(ctx, target) {
33531
33975
  try {
33532
33976
  delete target.currentTime;
33533
33977
  delete target.duration;
33978
+ delete target.paused;
33979
+ delete target.volume;
33980
+ delete target.muted;
33534
33981
  } catch {
33535
33982
  }
33536
33983
  },
@@ -33554,12 +34001,12 @@ var remuxPlugin = {
33554
34001
  var hybridPlugin = {
33555
34002
  name: "hybrid",
33556
34003
  canHandle: () => typeof VideoDecoder !== "undefined",
33557
- execute: (ctx, video) => createHybridSession(ctx, video)
34004
+ execute: (ctx, video, transport) => createHybridSession(ctx, video, transport)
33558
34005
  };
33559
34006
  var fallbackPlugin = {
33560
34007
  name: "fallback",
33561
34008
  canHandle: () => true,
33562
- execute: (ctx, video) => createFallbackSession(ctx, video)
34009
+ execute: (ctx, video, transport) => createFallbackSession(ctx, video, transport)
33563
34010
  };
33564
34011
  function registerBuiltins(registry) {
33565
34012
  registry.register(nativePlugin);
@@ -33568,6 +34015,9 @@ function registerBuiltins(registry) {
33568
34015
  registry.register(fallbackPlugin);
33569
34016
  }
33570
34017
 
34018
+ // src/subtitles/index.ts
34019
+ init_transport();
34020
+
33571
34021
  // src/subtitles/srt.ts
33572
34022
  function srtToVtt(srt) {
33573
34023
  if (srt.charCodeAt(0) === 65279) srt = srt.slice(1);
@@ -33645,7 +34095,8 @@ var SubtitleResourceBag = class {
33645
34095
  this.urls.clear();
33646
34096
  }
33647
34097
  };
33648
- async function attachSubtitleTracks(video, tracks, bag, onError) {
34098
+ async function attachSubtitleTracks(video, tracks, bag, onError, transport) {
34099
+ const doFetch = fetchWith(transport);
33649
34100
  for (const t of Array.from(video.querySelectorAll("track[data-avbridge]"))) {
33650
34101
  t.remove();
33651
34102
  }
@@ -33654,13 +34105,13 @@ async function attachSubtitleTracks(video, tracks, bag, onError) {
33654
34105
  try {
33655
34106
  let url2 = t.sidecarUrl;
33656
34107
  if (t.format === "srt") {
33657
- const res = await fetch(t.sidecarUrl);
34108
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
33658
34109
  const text = await res.text();
33659
34110
  const vtt = srtToVtt(text);
33660
34111
  const blob = new Blob([vtt], { type: "text/vtt" });
33661
34112
  url2 = bag ? bag.createObjectURL(blob) : URL.createObjectURL(blob);
33662
34113
  } else if (t.format === "vtt") {
33663
- const res = await fetch(t.sidecarUrl);
34114
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
33664
34115
  const text = await res.text();
33665
34116
  if (!isVtt(text)) {
33666
34117
  console.warn("[avbridge] subtitle missing WEBVTT header:", t.sidecarUrl);
@@ -33682,6 +34133,7 @@ async function attachSubtitleTracks(video, tracks, bag, onError) {
33682
34133
 
33683
34134
  // src/player.ts
33684
34135
  init_debug();
34136
+ init_errors();
33685
34137
  var UnifiedPlayer = class _UnifiedPlayer {
33686
34138
  /**
33687
34139
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
@@ -33689,6 +34141,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
33689
34141
  constructor(options, registry) {
33690
34142
  this.options = options;
33691
34143
  this.registry = registry;
34144
+ const { requestInit, fetchFn } = options;
34145
+ if (requestInit || fetchFn) {
34146
+ this.transport = { requestInit, fetchFn };
34147
+ }
33692
34148
  }
33693
34149
  options;
33694
34150
  registry;
@@ -33704,11 +34160,27 @@ var UnifiedPlayer = class _UnifiedPlayer {
33704
34160
  lastProgressTime = 0;
33705
34161
  lastProgressPosition = -1;
33706
34162
  errorListener = null;
34163
+ // Bound so we can removeEventListener in destroy(); without this the
34164
+ // listener outlives the player and accumulates on elements that swap
34165
+ // source (e.g. <avbridge-video>).
34166
+ endedListener = null;
34167
+ // Background tab handling. userIntent is what the user last asked for
34168
+ // (play vs pause) — used to decide whether to auto-resume on visibility
34169
+ // return. autoPausedForVisibility tracks whether we paused because the
34170
+ // tab was hidden, so we don't resume playback the user deliberately
34171
+ // paused (e.g. via media keys while hidden).
34172
+ userIntent = "pause";
34173
+ autoPausedForVisibility = false;
34174
+ visibilityListener = null;
33707
34175
  // Serializes escalation / setStrategy calls
33708
34176
  switchingPromise = Promise.resolve();
33709
34177
  // Owns blob URLs created during sidecar discovery + SRT->VTT conversion.
33710
34178
  // Revoked at destroy() so repeated source swaps don't leak.
33711
34179
  subtitleResources = new SubtitleResourceBag();
34180
+ // Transport config extracted from CreatePlayerOptions. Threaded to probe,
34181
+ // subtitle fetches, and strategy session creators. Not stored on MediaContext
34182
+ // because it's runtime config, not media analysis.
34183
+ transport;
33712
34184
  static async create(options) {
33713
34185
  const registry = new PluginRegistry();
33714
34186
  registerBuiltins(registry);
@@ -33732,7 +34204,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33732
34204
  const bootstrapStart = performance.now();
33733
34205
  try {
33734
34206
  dbg.info("bootstrap", "start");
33735
- const ctx = await dbg.timed("probe", "probe", 3e3, () => probe(this.options.source));
34207
+ const ctx = await dbg.timed("probe", "probe", 3e3, () => probe(this.options.source, this.transport));
33736
34208
  dbg.info(
33737
34209
  "probe",
33738
34210
  `container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`
@@ -33780,7 +34252,8 @@ var UnifiedPlayer = class _UnifiedPlayer {
33780
34252
  this.subtitleResources,
33781
34253
  (err, track) => {
33782
34254
  console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
33783
- }
34255
+ },
34256
+ this.transport
33784
34257
  );
33785
34258
  }
33786
34259
  this.emitter.emitSticky("tracks", {
@@ -33789,7 +34262,12 @@ var UnifiedPlayer = class _UnifiedPlayer {
33789
34262
  subtitle: ctx.subtitleTracks
33790
34263
  });
33791
34264
  this.startTimeupdateLoop();
33792
- this.options.target.addEventListener("ended", () => this.emitter.emit("ended", void 0));
34265
+ this.endedListener = () => this.emitter.emit("ended", void 0);
34266
+ this.options.target.addEventListener("ended", this.endedListener);
34267
+ if (this.options.backgroundBehavior !== "continue" && typeof document !== "undefined") {
34268
+ this.visibilityListener = () => this.onVisibilityChange();
34269
+ document.addEventListener("visibilitychange", this.visibilityListener);
34270
+ }
33793
34271
  this.emitter.emitSticky("ready", void 0);
33794
34272
  const bootstrapElapsed = performance.now() - bootstrapStart;
33795
34273
  dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
@@ -33816,7 +34294,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33816
34294
  throw new Error(`no plugin available for strategy "${strategy}"`);
33817
34295
  }
33818
34296
  try {
33819
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34297
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
33820
34298
  } catch (err) {
33821
34299
  const chain2 = this.classification?.fallbackChain;
33822
34300
  if (chain2 && chain2.length > 0) {
@@ -33889,7 +34367,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33889
34367
  continue;
33890
34368
  }
33891
34369
  try {
33892
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34370
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
33893
34371
  } catch (err) {
33894
34372
  const msg = err instanceof Error ? err.message : String(err);
33895
34373
  errors.push(`${nextStrategy}: ${msg}`);
@@ -33912,8 +34390,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
33912
34390
  }
33913
34391
  return;
33914
34392
  }
33915
- this.emitter.emit("error", new Error(
33916
- `all fallback strategies failed: ${errors.join("; ")}`
34393
+ this.emitter.emit("error", new AvbridgeError(
34394
+ ERR_ALL_STRATEGIES_EXHAUSTED,
34395
+ `All playback strategies failed: ${errors.join("; ")}`,
34396
+ "This file may require a codec or container that isn't available in this browser. Try the fallback strategy or check browser codec support."
33917
34397
  ));
33918
34398
  }
33919
34399
  // ── Stall supervision ─────────────────────────────────────────────────
@@ -33965,7 +34445,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33965
34445
  // ── Public: manual strategy switch ────────────────────────────────────
33966
34446
  /** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
33967
34447
  async setStrategy(strategy, reason) {
33968
- if (!this.mediaContext) throw new Error("player not ready");
34448
+ if (!this.mediaContext) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
33969
34449
  if (this.session?.strategy === strategy) return;
33970
34450
  this.switchingPromise = this.switchingPromise.then(
33971
34451
  () => this.doSetStrategy(strategy, reason)
@@ -33994,7 +34474,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33994
34474
  }
33995
34475
  const plugin = this.registry.findFor(this.mediaContext, strategy);
33996
34476
  if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
33997
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34477
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
33998
34478
  this.emitter.emitSticky("strategy", {
33999
34479
  strategy,
34000
34480
  reason: switchReason
@@ -34028,26 +34508,56 @@ var UnifiedPlayer = class _UnifiedPlayer {
34028
34508
  }
34029
34509
  /** Begin or resume playback. Throws if the player is not ready. */
34030
34510
  async play() {
34031
- if (!this.session) throw new Error("player not ready");
34511
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34512
+ this.userIntent = "play";
34513
+ this.autoPausedForVisibility = false;
34032
34514
  await this.session.play();
34033
34515
  }
34034
34516
  /** Pause playback. No-op if the player is not ready or already paused. */
34035
34517
  pause() {
34518
+ this.userIntent = "pause";
34519
+ this.autoPausedForVisibility = false;
34036
34520
  this.session?.pause();
34037
34521
  }
34522
+ /**
34523
+ * Handle browser tab visibility changes. On hide: pause if the user
34524
+ * had been playing. On show: resume if we were the one who paused.
34525
+ * Skips when `backgroundBehavior: "continue"` is set (listener isn't
34526
+ * installed in that case).
34527
+ */
34528
+ onVisibilityChange() {
34529
+ if (!this.session) return;
34530
+ const action = decideVisibilityAction({
34531
+ hidden: document.hidden,
34532
+ userIntent: this.userIntent,
34533
+ sessionIsPlaying: !this.options.target.paused,
34534
+ autoPausedForVisibility: this.autoPausedForVisibility
34535
+ });
34536
+ if (action === "pause") {
34537
+ this.autoPausedForVisibility = true;
34538
+ dbg.info("visibility", "tab hidden \u2014 auto-paused");
34539
+ this.session.pause();
34540
+ } else if (action === "resume") {
34541
+ this.autoPausedForVisibility = false;
34542
+ dbg.info("visibility", "tab visible \u2014 auto-resuming");
34543
+ void this.session.play().catch((err) => {
34544
+ console.warn("[avbridge] auto-resume after tab return failed:", err);
34545
+ });
34546
+ }
34547
+ }
34038
34548
  /** Seek to the given time in seconds. Throws if the player is not ready. */
34039
34549
  async seek(time) {
34040
- if (!this.session) throw new Error("player not ready");
34550
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34041
34551
  await this.session.seek(time);
34042
34552
  }
34043
34553
  /** Switch the active audio track by track ID. Throws if the player is not ready. */
34044
34554
  async setAudioTrack(id) {
34045
- if (!this.session) throw new Error("player not ready");
34555
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34046
34556
  await this.session.setAudioTrack(id);
34047
34557
  }
34048
34558
  /** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
34049
34559
  async setSubtitleTrack(id) {
34050
- if (!this.session) throw new Error("player not ready");
34560
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34051
34561
  await this.session.setSubtitleTrack(id);
34052
34562
  }
34053
34563
  /** Return a snapshot of current diagnostics: container, codecs, strategy, runtime stats, and strategy history. */
@@ -34075,6 +34585,14 @@ var UnifiedPlayer = class _UnifiedPlayer {
34075
34585
  this.timeupdateInterval = null;
34076
34586
  }
34077
34587
  this.clearSupervisor();
34588
+ if (this.endedListener) {
34589
+ this.options.target.removeEventListener("ended", this.endedListener);
34590
+ this.endedListener = null;
34591
+ }
34592
+ if (this.visibilityListener) {
34593
+ document.removeEventListener("visibilitychange", this.visibilityListener);
34594
+ this.visibilityListener = null;
34595
+ }
34078
34596
  if (this.session) {
34079
34597
  await this.session.destroy();
34080
34598
  this.session = null;
@@ -34086,14 +34604,24 @@ var UnifiedPlayer = class _UnifiedPlayer {
34086
34604
  async function createPlayer(options) {
34087
34605
  return UnifiedPlayer.create(options);
34088
34606
  }
34607
+ function decideVisibilityAction(state) {
34608
+ if (state.hidden) {
34609
+ if (state.userIntent === "play" && state.sessionIsPlaying) return "pause";
34610
+ return "noop";
34611
+ }
34612
+ if (state.autoPausedForVisibility) return "resume";
34613
+ return "noop";
34614
+ }
34089
34615
  function buildInitialDecision(initial, ctx) {
34090
34616
  const natural = classifyContext(ctx);
34091
34617
  const cls = strategyToClass(initial, natural);
34618
+ const inherited = natural.fallbackChain ?? defaultFallbackChain(initial);
34619
+ const fallbackChain = inherited.filter((s) => s !== initial);
34092
34620
  return {
34093
34621
  class: cls,
34094
34622
  strategy: initial,
34095
34623
  reason: `initial strategy "${initial}" requested via options.initialStrategy`,
34096
- fallbackChain: natural.fallbackChain ?? defaultFallbackChain(initial)
34624
+ fallbackChain
34097
34625
  };
34098
34626
  }
34099
34627
  function strategyToClass(strategy, natural) {