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
package/src/player.ts CHANGED
@@ -15,8 +15,10 @@ import type {
15
15
  PlayerEventMap,
16
16
  PlayerEventName,
17
17
  StrategyName,
18
+ TransportConfig,
18
19
  Listener,
19
20
  } from "./types.js";
21
+ import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
20
22
 
21
23
  export class UnifiedPlayer {
22
24
  private emitter = new TypedEmitter<PlayerEventMap>();
@@ -34,6 +36,20 @@ export class UnifiedPlayer {
34
36
  private lastProgressPosition = -1;
35
37
  private errorListener: (() => void) | null = null;
36
38
 
39
+ // Bound so we can removeEventListener in destroy(); without this the
40
+ // listener outlives the player and accumulates on elements that swap
41
+ // source (e.g. <avbridge-video>).
42
+ private endedListener: (() => void) | null = null;
43
+
44
+ // Background tab handling. userIntent is what the user last asked for
45
+ // (play vs pause) — used to decide whether to auto-resume on visibility
46
+ // return. autoPausedForVisibility tracks whether we paused because the
47
+ // tab was hidden, so we don't resume playback the user deliberately
48
+ // paused (e.g. via media keys while hidden).
49
+ private userIntent: "play" | "pause" = "pause";
50
+ private autoPausedForVisibility = false;
51
+ private visibilityListener: (() => void) | null = null;
52
+
37
53
  // Serializes escalation / setStrategy calls
38
54
  private switchingPromise: Promise<void> = Promise.resolve();
39
55
 
@@ -41,13 +57,23 @@ export class UnifiedPlayer {
41
57
  // Revoked at destroy() so repeated source swaps don't leak.
42
58
  private subtitleResources = new SubtitleResourceBag();
43
59
 
60
+ // Transport config extracted from CreatePlayerOptions. Threaded to probe,
61
+ // subtitle fetches, and strategy session creators. Not stored on MediaContext
62
+ // because it's runtime config, not media analysis.
63
+ private readonly transport: TransportConfig | undefined;
64
+
44
65
  /**
45
66
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
46
67
  */
47
68
  private constructor(
48
69
  private readonly options: CreatePlayerOptions,
49
70
  private readonly registry: PluginRegistry,
50
- ) {}
71
+ ) {
72
+ const { requestInit, fetchFn } = options;
73
+ if (requestInit || fetchFn) {
74
+ this.transport = { requestInit, fetchFn };
75
+ }
76
+ }
51
77
 
52
78
  static async create(options: CreatePlayerOptions): Promise<UnifiedPlayer> {
53
79
  const registry = new PluginRegistry();
@@ -69,7 +95,7 @@ export class UnifiedPlayer {
69
95
  const bootstrapStart = performance.now();
70
96
  try {
71
97
  dbg.info("bootstrap", "start");
72
- const ctx = await dbg.timed("probe", "probe", 3000, () => probe(this.options.source));
98
+ const ctx = await dbg.timed("probe", "probe", 3000, () => probe(this.options.source, this.transport));
73
99
  dbg.info("probe",
74
100
  `container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} ` +
75
101
  `audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`,
@@ -121,12 +147,10 @@ export class UnifiedPlayer {
121
147
  // Try the primary strategy, falling through the chain on failure
122
148
  await this.startSession(decision.strategy, decision.reason);
123
149
 
124
- // Apply subtitles for non-canvas strategies. Awaited so fetch/parse
125
- // failures surface deterministically but per-track failures are
126
- // caught inside attachSubtitleTracks and logged via console.warn so
127
- // a single bad sidecar doesn't break bootstrap. Subtitles are not
128
- // load-bearing for playback. (Promoting this to a typed `subtitleerror`
129
- // event is a nice-to-have follow-up.)
150
+ // Apply subtitles for non-canvas strategies. Per-track failures are
151
+ // caught inside attachSubtitleTracks and logged via console.warn
152
+ // subtitles are not load-bearing, so a bad sidecar must not break
153
+ // bootstrap.
130
154
  if (this.session!.strategy !== "fallback" && this.session!.strategy !== "hybrid") {
131
155
  await attachSubtitleTracks(
132
156
  this.options.target,
@@ -136,6 +160,7 @@ export class UnifiedPlayer {
136
160
  // eslint-disable-next-line no-console
137
161
  console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
138
162
  },
163
+ this.transport,
139
164
  );
140
165
  }
141
166
 
@@ -146,7 +171,17 @@ export class UnifiedPlayer {
146
171
  });
147
172
 
148
173
  this.startTimeupdateLoop();
149
- this.options.target.addEventListener("ended", () => this.emitter.emit("ended", undefined));
174
+ this.endedListener = () => this.emitter.emit("ended", undefined);
175
+ this.options.target.addEventListener("ended", this.endedListener);
176
+
177
+ // Auto-pause on background tab (unless explicitly opted out).
178
+ // Chrome throttles rAF and setTimeout in hidden tabs, so playback
179
+ // degrades anyway — better to pause cleanly and resume on return.
180
+ if (this.options.backgroundBehavior !== "continue" && typeof document !== "undefined") {
181
+ this.visibilityListener = () => this.onVisibilityChange();
182
+ document.addEventListener("visibilitychange", this.visibilityListener);
183
+ }
184
+
150
185
  this.emitter.emitSticky("ready", undefined);
151
186
  const bootstrapElapsed = performance.now() - bootstrapStart;
152
187
  dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
@@ -177,7 +212,7 @@ export class UnifiedPlayer {
177
212
  }
178
213
 
179
214
  try {
180
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
215
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
181
216
  } catch (err) {
182
217
  // Try the fallback chain
183
218
  const chain = this.classification?.fallbackChain;
@@ -271,7 +306,7 @@ export class UnifiedPlayer {
271
306
  }
272
307
 
273
308
  try {
274
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
309
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
275
310
  } catch (err) {
276
311
  const msg = err instanceof Error ? err.message : String(err);
277
312
  errors.push(`${nextStrategy}: ${msg}`);
@@ -298,8 +333,10 @@ export class UnifiedPlayer {
298
333
  }
299
334
 
300
335
  // Chain exhausted with no working strategy.
301
- this.emitter.emit("error", new Error(
302
- `all fallback strategies failed: ${errors.join("; ")}`,
336
+ this.emitter.emit("error", new AvbridgeError(
337
+ ERR_ALL_STRATEGIES_EXHAUSTED,
338
+ `All playback strategies failed: ${errors.join("; ")}`,
339
+ "This file may require a codec or container that isn't available in this browser. Try the fallback strategy or check browser codec support.",
303
340
  ));
304
341
  }
305
342
 
@@ -362,7 +399,7 @@ export class UnifiedPlayer {
362
399
 
363
400
  /** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
364
401
  async setStrategy(strategy: StrategyName, reason?: string): Promise<void> {
365
- if (!this.mediaContext) throw new Error("player not ready");
402
+ if (!this.mediaContext) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
366
403
  if (this.session?.strategy === strategy) return;
367
404
 
368
405
  this.switchingPromise = this.switchingPromise.then(() =>
@@ -394,7 +431,7 @@ export class UnifiedPlayer {
394
431
  const plugin = this.registry.findFor(this.mediaContext!, strategy);
395
432
  if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
396
433
 
397
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
434
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
398
435
 
399
436
  this.emitter.emitSticky("strategy", {
400
437
  strategy,
@@ -437,30 +474,62 @@ export class UnifiedPlayer {
437
474
 
438
475
  /** Begin or resume playback. Throws if the player is not ready. */
439
476
  async play(): Promise<void> {
440
- if (!this.session) throw new Error("player not ready");
477
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
478
+ this.userIntent = "play";
479
+ this.autoPausedForVisibility = false;
441
480
  await this.session.play();
442
481
  }
443
482
 
444
483
  /** Pause playback. No-op if the player is not ready or already paused. */
445
484
  pause(): void {
485
+ this.userIntent = "pause";
486
+ this.autoPausedForVisibility = false;
446
487
  this.session?.pause();
447
488
  }
448
489
 
490
+ /**
491
+ * Handle browser tab visibility changes. On hide: pause if the user
492
+ * had been playing. On show: resume if we were the one who paused.
493
+ * Skips when `backgroundBehavior: "continue"` is set (listener isn't
494
+ * installed in that case).
495
+ */
496
+ private onVisibilityChange(): void {
497
+ if (!this.session) return;
498
+ const action = decideVisibilityAction({
499
+ hidden: document.hidden,
500
+ userIntent: this.userIntent,
501
+ sessionIsPlaying: !this.options.target.paused,
502
+ autoPausedForVisibility: this.autoPausedForVisibility,
503
+ });
504
+ if (action === "pause") {
505
+ this.autoPausedForVisibility = true;
506
+ dbg.info("visibility", "tab hidden — auto-paused");
507
+ this.session.pause();
508
+ } else if (action === "resume") {
509
+ this.autoPausedForVisibility = false;
510
+ dbg.info("visibility", "tab visible — auto-resuming");
511
+ void this.session.play().catch((err) => {
512
+ // eslint-disable-next-line no-console
513
+ console.warn("[avbridge] auto-resume after tab return failed:", err);
514
+ });
515
+ }
516
+ }
517
+
449
518
  /** Seek to the given time in seconds. Throws if the player is not ready. */
450
519
  async seek(time: number): Promise<void> {
451
- if (!this.session) throw new Error("player not ready");
520
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
452
521
  await this.session.seek(time);
453
522
  }
454
523
 
455
524
  /** Switch the active audio track by track ID. Throws if the player is not ready. */
456
525
  async setAudioTrack(id: number): Promise<void> {
457
- if (!this.session) throw new Error("player not ready");
526
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
458
527
  await this.session.setAudioTrack(id);
459
528
  }
460
529
 
461
530
  /** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
462
531
  async setSubtitleTrack(id: number | null): Promise<void> {
463
- if (!this.session) throw new Error("player not ready");
532
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
464
533
  await this.session.setSubtitleTrack(id);
465
534
  }
466
535
 
@@ -492,6 +561,14 @@ export class UnifiedPlayer {
492
561
  this.timeupdateInterval = null;
493
562
  }
494
563
  this.clearSupervisor();
564
+ if (this.endedListener) {
565
+ this.options.target.removeEventListener("ended", this.endedListener);
566
+ this.endedListener = null;
567
+ }
568
+ if (this.visibilityListener) {
569
+ document.removeEventListener("visibilitychange", this.visibilityListener);
570
+ this.visibilityListener = null;
571
+ }
495
572
  if (this.session) {
496
573
  await this.session.destroy();
497
574
  this.session = null;
@@ -508,14 +585,35 @@ export async function createPlayer(options: CreatePlayerOptions): Promise<Unifie
508
585
  }
509
586
 
510
587
  /**
511
- * Build a synthetic classification when the consumer asked for a specific
512
- * initial strategy. We ask `classify()` for the natural decision so we can
513
- * inherit the correct fallback chain, then override the strategy + class.
588
+ * Pure decision function for visibility-change handling. Separated from
589
+ * the class method so it can be unit-tested without a full player
590
+ * instance.
514
591
  *
515
- * Why this matters: hard-coding `class: "NATIVE"` (the old behavior) made
516
- * diagnostics lie for every initialStrategy that wasn't actually native, and
517
- * any downstream logic that trusted `strategyClass` could make the wrong
518
- * decision. We now derive the class from the picked strategy.
592
+ * @internal exported for unit tests; not part of the public API.
593
+ */
594
+ export function decideVisibilityAction(state: {
595
+ hidden: boolean;
596
+ userIntent: "play" | "pause";
597
+ sessionIsPlaying: boolean;
598
+ autoPausedForVisibility: boolean;
599
+ }): "pause" | "resume" | "noop" {
600
+ if (state.hidden) {
601
+ // Tab hidden: pause if user had been playing and session is active
602
+ if (state.userIntent === "play" && state.sessionIsPlaying) return "pause";
603
+ return "noop";
604
+ }
605
+ // Tab visible: resume only if we're the one who paused
606
+ if (state.autoPausedForVisibility) return "resume";
607
+ return "noop";
608
+ }
609
+
610
+ /**
611
+ * Build a synthetic classification for an explicit `initialStrategy` override.
612
+ * The `class` is derived from the chosen strategy so diagnostics and any
613
+ * downstream consumer of `strategyClass` see the real strategy. The fallback
614
+ * chain is inherited from the natural classification but must never contain
615
+ * `initial` itself — otherwise `startSession` would retry the strategy that
616
+ * just failed before escalating.
519
617
  *
520
618
  * @internal — exported for unit tests; not part of the public API.
521
619
  */
@@ -525,11 +623,13 @@ export function buildInitialDecision(
525
623
  ): Classification {
526
624
  const natural = classify(ctx);
527
625
  const cls = strategyToClass(initial, natural);
626
+ const inherited = natural.fallbackChain ?? defaultFallbackChain(initial);
627
+ const fallbackChain = inherited.filter((s) => s !== initial);
528
628
  return {
529
629
  class: cls,
530
630
  strategy: initial,
531
631
  reason: `initial strategy "${initial}" requested via options.initialStrategy`,
532
- fallbackChain: natural.fallbackChain ?? defaultFallbackChain(initial),
632
+ fallbackChain,
533
633
  };
534
634
  }
535
635
 
@@ -20,13 +20,13 @@ const remuxPlugin: Plugin = {
20
20
  const hybridPlugin: Plugin = {
21
21
  name: "hybrid",
22
22
  canHandle: () => typeof VideoDecoder !== "undefined",
23
- execute: (ctx, video) => createHybridSession(ctx, video),
23
+ execute: (ctx, video, transport) => createHybridSession(ctx, video, transport),
24
24
  };
25
25
 
26
26
  const fallbackPlugin: Plugin = {
27
27
  name: "fallback",
28
28
  canHandle: () => true,
29
- execute: (ctx, video) => createFallbackSession(ctx, video),
29
+ execute: (ctx, video, transport) => createFallbackSession(ctx, video, transport),
30
30
  };
31
31
 
32
32
  export function registerBuiltins(registry: PluginRegistry): void {
package/src/probe/avi.ts CHANGED
@@ -205,6 +205,10 @@ function ffmpegToAvbridgeAudio(name: string): AudioCodec {
205
205
  case "ra_288": return "ra_288";
206
206
  case "sipr": return "sipr";
207
207
  case "atrac3": return "atrac3";
208
+ case "dca":
209
+ case "dts": return "dts";
210
+ case "truehd":
211
+ case "mlp": return "truehd";
208
212
  default: return name as AudioCodec;
209
213
  }
210
214
  }
@@ -1,6 +1,7 @@
1
- import type { ContainerKind, MediaContext, MediaInput } from "../types.js";
1
+ import type { ContainerKind, MediaContext, MediaInput, TransportConfig } from "../types.js";
2
2
  import { normalizeSource, sniffNormalizedSource } from "../util/source.js";
3
3
  import { probeWithMediabunny } from "./mediabunny.js";
4
+ import { AvbridgeError, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_LIBAV_NOT_REACHABLE } from "../errors.js";
4
5
 
5
6
  /** Containers mediabunny can demux. Sniff results outside this set go straight to libav. */
6
7
  const MEDIABUNNY_CONTAINERS = new Set<ContainerKind>([
@@ -32,13 +33,33 @@ const MEDIABUNNY_CONTAINERS = new Set<ContainerKind>([
32
33
  * mediabunny can't read those containers at all, so there's no fast path
33
34
  * to try.
34
35
  */
35
- export async function probe(source: MediaInput): Promise<MediaContext> {
36
- const normalized = await normalizeSource(source);
36
+ export async function probe(
37
+ source: MediaInput,
38
+ transport?: TransportConfig,
39
+ ): Promise<MediaContext> {
40
+ const normalized = await normalizeSource(source, transport);
37
41
  const sniffed = await sniffNormalizedSource(normalized);
38
42
 
39
43
  if (MEDIABUNNY_CONTAINERS.has(sniffed)) {
40
44
  try {
41
- return await probeWithMediabunny(normalized, sniffed);
45
+ const result = await probeWithMediabunny(normalized, sniffed);
46
+ // If mediabunny returned unknown codecs, try libav which recognizes
47
+ // a wider range (DTS, TrueHD, etc.). Only escalate if there ARE
48
+ // tracks with unknown codecs — otherwise mediabunny's result is fine.
49
+ const hasUnknownCodec =
50
+ result.videoTracks.some((t) => t.codec === "unknown") ||
51
+ result.audioTracks.some((t) => t.codec === "unknown");
52
+ if (hasUnknownCodec) {
53
+ try {
54
+ const { probeWithLibav } = await import("./avi.js");
55
+ return await probeWithLibav(normalized, sniffed);
56
+ } catch {
57
+ // libav also failed — return mediabunny's result (unknown codecs
58
+ // are better than no result at all)
59
+ return result;
60
+ }
61
+ }
62
+ return result;
42
63
  } catch (mediabunnyErr) {
43
64
  // mediabunny rejected the file. Before giving up, try libav — it can
44
65
  // demux a much wider range of codec combinations in ISOBMFF/MKV/etc.
@@ -56,8 +77,10 @@ export async function probe(source: MediaInput): Promise<MediaContext> {
56
77
  } catch (libavErr) {
57
78
  const mbMsg = (mediabunnyErr as Error).message || String(mediabunnyErr);
58
79
  const lvMsg = libavErr instanceof Error ? libavErr.message : String(libavErr);
59
- throw new Error(
60
- `failed to probe ${sniffed} file. mediabunny: ${mbMsg}. libav fallback: ${lvMsg}.`,
80
+ throw new AvbridgeError(
81
+ ERR_PROBE_FAILED,
82
+ `Failed to probe ${sniffed.toUpperCase()} file. mediabunny: ${mbMsg}. libav: ${lvMsg}.`,
83
+ "The file may be corrupt, truncated, or in an unsupported format. Enable AVBRIDGE_DEBUG for detailed logs.",
61
84
  );
62
85
  }
63
86
  }
@@ -71,10 +94,17 @@ export async function probe(source: MediaInput): Promise<MediaContext> {
71
94
  const inner = err instanceof Error ? err.message : String(err);
72
95
  // eslint-disable-next-line no-console
73
96
  console.error("[avbridge] libav probe failed for", sniffed, "file:", err);
74
- throw new Error(
75
- sniffed === "unknown"
76
- ? `unable to probe source: container could not be identified, and the libav.js fallback also failed: ${inner || "(no message — see browser console for the original error)"}`
77
- : `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no message — see browser console for the original error)"}`,
97
+ if (sniffed === "unknown") {
98
+ throw new AvbridgeError(
99
+ ERR_PROBE_UNKNOWN_CONTAINER,
100
+ `Unable to probe source: container format could not be identified. libav fallback: ${inner || "(no details)"}`,
101
+ "The file may be corrupt or in a format avbridge doesn't recognize. Check the file plays in VLC or ffprobe.",
102
+ );
103
+ }
104
+ throw new AvbridgeError(
105
+ ERR_LIBAV_NOT_REACHABLE,
106
+ `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no details)"}`,
107
+ "Install @libav.js/variant-webcodecs, or check that AVBRIDGE_LIBAV_BASE points to the correct path.",
78
108
  );
79
109
  }
80
110
  }
@@ -76,12 +76,42 @@ export class AudioOutput implements ClockSource {
76
76
  private framesScheduled = 0;
77
77
  private destroyed = false;
78
78
 
79
+ /** User-set volume (0..1). Applied to the gain node. */
80
+ private _volume = 1;
81
+ /** User-set muted flag. When true, gain is forced to 0. */
82
+ private _muted = false;
83
+
79
84
  constructor() {
80
85
  this.ctx = new AudioContext();
81
86
  this.gain = this.ctx.createGain();
82
87
  this.gain.connect(this.ctx.destination);
83
88
  }
84
89
 
90
+ /** Set volume (0..1). Applied immediately to the gain node. */
91
+ setVolume(v: number): void {
92
+ this._volume = Math.max(0, Math.min(1, v));
93
+ this.applyGain();
94
+ }
95
+
96
+ getVolume(): number {
97
+ return this._volume;
98
+ }
99
+
100
+ /** Set muted. When true, output is silenced regardless of volume. */
101
+ setMuted(m: boolean): void {
102
+ this._muted = m;
103
+ this.applyGain();
104
+ }
105
+
106
+ getMuted(): boolean {
107
+ return this._muted;
108
+ }
109
+
110
+ private applyGain(): void {
111
+ const target = this._muted ? 0 : this._volume;
112
+ try { this.gain.gain.value = target; } catch { /* ignore */ }
113
+ }
114
+
85
115
  /**
86
116
  * Switch into wall-clock fallback mode. Called by the decoder when no
87
117
  * audio decoder could be initialized for the source. Once set, this
@@ -287,6 +317,7 @@ export class AudioOutput implements ClockSource {
287
317
  try { this.gain.disconnect(); } catch { /* ignore */ }
288
318
  this.gain = this.ctx.createGain();
289
319
  this.gain.connect(this.ctx.destination);
320
+ this.applyGain();
290
321
 
291
322
  this.pendingQueue = [];
292
323
  this.mediaTimeOfAnchor = newMediaTime;
@@ -28,6 +28,7 @@ import { VideoRenderer } from "./video-renderer.js";
28
28
  import { AudioOutput } from "./audio-output.js";
29
29
  import type { MediaContext } from "../../types.js";
30
30
  import { pickLibavVariant } from "./variant-routing.js";
31
+ import { dbg } from "../../util/debug.js";
31
32
 
32
33
  export interface DecoderHandles {
33
34
  destroy(): Promise<void>;
@@ -43,6 +44,7 @@ export interface StartDecoderOptions {
43
44
  context: MediaContext;
44
45
  renderer: VideoRenderer;
45
46
  audio: AudioOutput;
47
+ transport?: import("../../types.js").TransportConfig;
46
48
  }
47
49
 
48
50
  export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHandles> {
@@ -54,7 +56,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
54
56
  // libav demuxes via Range requests. For Blob sources, it falls back to
55
57
  // mkreadaheadfile (in-memory). The returned handle owns cleanup.
56
58
  const { prepareLibavInput } = await import("../../util/libav-http-reader.js");
57
- const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source);
59
+ const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source, opts.transport);
58
60
 
59
61
  // Pre-allocate one AVPacket for ff_read_frame_multi to reuse.
60
62
  const readPkt = await libav.av_packet_alloc();
@@ -126,6 +128,67 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
126
128
  );
127
129
  }
128
130
 
131
+ // ── Bitstream filter for MPEG-4 Part 2 packed B-frames ───────────────
132
+ // Applied unconditionally for mpeg4 video — the BSF is a no-op when
133
+ // the stream doesn't actually have packed B-frames, so false positives
134
+ // are harmless. Without it, DivX files with packed B-frames produce
135
+ // garbled frame ordering.
136
+ let bsfCtx: number | null = null;
137
+ let bsfPkt: number | null = null;
138
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
139
+ try {
140
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
141
+ if (bsfCtx != null && bsfCtx >= 0) {
142
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
143
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
144
+ await libav.av_bsf_init(bsfCtx);
145
+ bsfPkt = await libav.av_packet_alloc();
146
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
147
+ } else {
148
+ // eslint-disable-next-line no-console
149
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
150
+ bsfCtx = null;
151
+ }
152
+ } catch (err) {
153
+ // eslint-disable-next-line no-console
154
+ console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
155
+ bsfCtx = null;
156
+ bsfPkt = null;
157
+ }
158
+ }
159
+
160
+ /** Run video packets through the BSF. Returns original packets if no BSF active. */
161
+ async function applyBSF(packets: LibavPacket[]): Promise<LibavPacket[]> {
162
+ if (!bsfCtx || !bsfPkt) return packets;
163
+ const out: LibavPacket[] = [];
164
+ for (const pkt of packets) {
165
+ await libav.ff_copyin_packet(bsfPkt, pkt);
166
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
167
+ if (sendErr < 0) {
168
+ out.push(pkt); // BSF rejected — pass through original
169
+ continue;
170
+ }
171
+ while (true) {
172
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
173
+ if (recvErr < 0) break; // EAGAIN or EOF
174
+ out.push(await libav.ff_copyout_packet(bsfPkt));
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** Flush the BSF (on seek or EOF) to drain any internally buffered packets. */
181
+ async function flushBSF(): Promise<void> {
182
+ if (!bsfCtx || !bsfPkt) return;
183
+ try {
184
+ await libav.av_bsf_send_packet(bsfCtx, 0);
185
+ while (true) {
186
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
187
+ if (err < 0) break;
188
+ }
189
+ } catch { /* ignore flush errors */ }
190
+ }
191
+
129
192
  // ── Mutable state shared across pump loops ───────────────────────────
130
193
  let destroyed = false;
131
194
  let pumpToken = 0; // bumped on seek; pump loops bail when token changes
@@ -202,7 +265,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
202
265
  }
203
266
  if (myToken !== pumpToken || destroyed) return;
204
267
  if (videoDec && videoPackets && videoPackets.length > 0) {
205
- await decodeVideoBatch(videoPackets, myToken);
268
+ const processed = await applyBSF(videoPackets);
269
+ await decodeVideoBatch(processed, myToken);
206
270
  }
207
271
 
208
272
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
@@ -382,6 +446,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
382
446
  destroyed = true;
383
447
  pumpToken++;
384
448
  try { await pumpRunning; } catch { /* ignore */ }
449
+ try { if (bsfCtx) await libav.av_bsf_free(bsfCtx); } catch { /* ignore */ }
450
+ try { if (bsfPkt) await libav.av_packet_free?.(bsfPkt); } catch { /* ignore */ }
385
451
  try { if (videoDec) await libav.ff_free_decoder?.(videoDec.c, videoDec.pkt, videoDec.frame); } catch { /* ignore */ }
386
452
  try { if (audioDec) await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
387
453
  try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
@@ -435,6 +501,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
435
501
  try {
436
502
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
437
503
  } catch { /* ignore */ }
504
+ await flushBSF();
438
505
 
439
506
  // Reset synthetic timestamp counters to the seek target so newly
440
507
  // decoded frames start at the right media time.
@@ -456,6 +523,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
456
523
  packetsRead,
457
524
  videoFramesDecoded,
458
525
  audioFramesDecoded,
526
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
459
527
  // Confirmed transport info: once prepareLibavInput returns
460
528
  // successfully, we *know* whether the source is http-range (probe
461
529
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -740,6 +808,19 @@ interface LibavRuntime {
740
808
  avformat_close_input_js(ctx: number): Promise<void>;
741
809
  /** Sync helper exposed by libav.js: split a JS number into (lo, hi) int64. */
742
810
  f64toi64?(val: number): [number, number];
811
+
812
+ // BSF (bitstream filter) methods — used for mpeg4_unpack_bframes
813
+ av_bsf_list_parse_str_js(str: string): Promise<number>;
814
+ AVBSFContext_par_in(ctx: number): Promise<number>;
815
+ avcodec_parameters_copy(dst: number, src: number): Promise<number>;
816
+ av_bsf_init(ctx: number): Promise<number>;
817
+ av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
818
+ av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
819
+ av_bsf_free(ctx: number): Promise<void>;
820
+
821
+ // Packet copy helpers — bridge JS packet objects to/from C-level pointers
822
+ ff_copyin_packet(pktPtr: number, packet: LibavPacket): Promise<void>;
823
+ ff_copyout_packet(pkt: number): Promise<LibavPacket>;
743
824
  }
744
825
 
745
826
  interface BridgeModule {