avbridge 2.2.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/NOTICE.md +2 -2
  3. package/README.md +2 -3
  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-2IJ66NTD.cjs +212 -0
  14. package/dist/chunk-2IJ66NTD.cjs.map +1 -0
  15. package/dist/{chunk-ILKDNBSE.js → chunk-2XW2O3YI.cjs} +55 -10
  16. package/dist/chunk-2XW2O3YI.cjs.map +1 -0
  17. package/dist/chunk-5KVLE6YI.js +167 -0
  18. package/dist/chunk-5KVLE6YI.js.map +1 -0
  19. package/dist/chunk-5YAWWKA3.js +18 -0
  20. package/dist/chunk-5YAWWKA3.js.map +1 -0
  21. package/dist/chunk-CPJLFFCC.js +189 -0
  22. package/dist/chunk-CPJLFFCC.js.map +1 -0
  23. package/dist/chunk-CPZ7PXAM.cjs +240 -0
  24. package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
  25. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  26. package/dist/chunk-DCSOQH2N.js.map +1 -0
  27. package/dist/{chunk-HZLQNKFN.cjs → chunk-E76AMWI4.js} +40 -15
  28. package/dist/chunk-E76AMWI4.js.map +1 -0
  29. package/dist/chunk-F3LQJKXK.cjs +20 -0
  30. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  31. package/dist/chunk-IAYKFGFG.js +200 -0
  32. package/dist/chunk-IAYKFGFG.js.map +1 -0
  33. package/dist/{chunk-DMWARSEF.js → chunk-KY2GPCT7.js} +788 -697
  34. package/dist/chunk-KY2GPCT7.js.map +1 -0
  35. package/dist/chunk-LUFA47FP.js +19 -0
  36. package/dist/chunk-LUFA47FP.js.map +1 -0
  37. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  38. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  39. package/dist/chunk-Q2VUO52Z.cjs +374 -0
  40. package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
  41. package/dist/chunk-QDJLQR53.cjs +22 -0
  42. package/dist/chunk-QDJLQR53.cjs.map +1 -0
  43. package/dist/chunk-S4WAZC2T.cjs +173 -0
  44. package/dist/chunk-S4WAZC2T.cjs.map +1 -0
  45. package/dist/chunk-SMH6IOP2.js +368 -0
  46. package/dist/chunk-SMH6IOP2.js.map +1 -0
  47. package/dist/chunk-SR3MPV4D.js +237 -0
  48. package/dist/chunk-SR3MPV4D.js.map +1 -0
  49. package/dist/{chunk-UF2N5L63.cjs → chunk-TBW26OPP.cjs} +800 -710
  50. package/dist/chunk-TBW26OPP.cjs.map +1 -0
  51. package/dist/chunk-X2K3GIWE.js +235 -0
  52. package/dist/chunk-X2K3GIWE.js.map +1 -0
  53. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  54. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  55. package/dist/chunk-ZCUXHW55.cjs +242 -0
  56. package/dist/chunk-ZCUXHW55.cjs.map +1 -0
  57. package/dist/element-browser.js +1282 -503
  58. package/dist/element-browser.js.map +1 -1
  59. package/dist/element.cjs +59 -5
  60. package/dist/element.cjs.map +1 -1
  61. package/dist/element.d.cts +39 -1
  62. package/dist/element.d.ts +39 -1
  63. package/dist/element.js +58 -4
  64. package/dist/element.js.map +1 -1
  65. package/dist/index.cjs +605 -327
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +48 -4
  68. package/dist/index.d.ts +48 -4
  69. package/dist/index.js +528 -319
  70. package/dist/index.js.map +1 -1
  71. package/dist/libav-demux-H2GS46GH.cjs +27 -0
  72. package/dist/{libav-http-reader-NQJVY273.js.map → libav-demux-H2GS46GH.cjs.map} +1 -1
  73. package/dist/libav-demux-OWZ4T2YW.js +6 -0
  74. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-demux-OWZ4T2YW.js.map} +1 -1
  75. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  76. package/dist/libav-http-reader-AZLE7YFS.cjs.map +1 -0
  77. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  78. package/dist/libav-http-reader-WXG3Z7AI.js.map +1 -0
  79. package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
  80. package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
  81. package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
  82. package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
  83. package/dist/{player-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
  84. package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
  85. package/dist/player.cjs +5631 -0
  86. package/dist/player.cjs.map +1 -0
  87. package/dist/player.d.cts +699 -0
  88. package/dist/player.d.ts +699 -0
  89. package/dist/player.js +5629 -0
  90. package/dist/player.js.map +1 -0
  91. package/dist/remux-OBSMIENG.cjs +35 -0
  92. package/dist/remux-OBSMIENG.cjs.map +1 -0
  93. package/dist/remux-WBYIZBBX.js +10 -0
  94. package/dist/remux-WBYIZBBX.js.map +1 -0
  95. package/dist/source-4TZ6KMNV.js +4 -0
  96. package/dist/{source-FFZ7TW2B.js.map → source-4TZ6KMNV.js.map} +1 -1
  97. package/dist/source-7YLO6E7X.cjs +29 -0
  98. package/dist/{source-CN43EI7Z.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
  99. package/dist/source-MTX5ELUZ.js +4 -0
  100. package/dist/source-MTX5ELUZ.js.map +1 -0
  101. package/dist/source-VFLXLOCN.cjs +29 -0
  102. package/dist/source-VFLXLOCN.cjs.map +1 -0
  103. package/dist/subtitles-4T74JRGT.js +4 -0
  104. package/dist/subtitles-4T74JRGT.js.map +1 -0
  105. package/dist/subtitles-QUH4LPI4.cjs +29 -0
  106. package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
  107. package/dist/variant-routing-434STYAB.js +3 -0
  108. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  109. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  110. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  111. package/package.json +9 -1
  112. package/src/classify/rules.ts +27 -5
  113. package/src/convert/remux.ts +9 -35
  114. package/src/convert/transcode-libav.ts +691 -0
  115. package/src/convert/transcode.ts +53 -12
  116. package/src/element/avbridge-player.ts +861 -0
  117. package/src/element/avbridge-video.ts +54 -0
  118. package/src/element/player-icons.ts +25 -0
  119. package/src/element/player-styles.ts +472 -0
  120. package/src/errors.ts +53 -0
  121. package/src/index.ts +23 -0
  122. package/src/player-element.ts +18 -0
  123. package/src/player.ts +118 -27
  124. package/src/plugins/builtin.ts +2 -2
  125. package/src/probe/avi.ts +4 -0
  126. package/src/probe/index.ts +40 -10
  127. package/src/strategies/fallback/audio-output.ts +31 -0
  128. package/src/strategies/fallback/decoder.ts +179 -175
  129. package/src/strategies/fallback/index.ts +48 -6
  130. package/src/strategies/fallback/libav-import.ts +9 -1
  131. package/src/strategies/fallback/variant-routing.ts +7 -13
  132. package/src/strategies/fallback/video-renderer.ts +231 -32
  133. package/src/strategies/hybrid/decoder.ts +219 -200
  134. package/src/strategies/hybrid/index.ts +48 -7
  135. package/src/strategies/native.ts +6 -3
  136. package/src/strategies/remux/index.ts +14 -2
  137. package/src/strategies/remux/mse.ts +12 -2
  138. package/src/strategies/remux/pipeline.ts +72 -12
  139. package/src/subtitles/index.ts +7 -3
  140. package/src/subtitles/render.ts +8 -0
  141. package/src/types.ts +53 -1
  142. package/src/util/libav-demux.ts +405 -0
  143. package/src/util/libav-http-reader.ts +5 -1
  144. package/src/util/source.ts +28 -8
  145. package/src/util/transport.ts +26 -0
  146. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  147. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  148. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  149. package/dist/avi-GCGM7OJI.js.map +0 -1
  150. package/dist/chunk-DMWARSEF.js.map +0 -1
  151. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  152. package/dist/chunk-ILKDNBSE.js.map +0 -1
  153. package/dist/chunk-J5MCMN3S.js +0 -27
  154. package/dist/chunk-J5MCMN3S.js.map +0 -1
  155. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  156. package/dist/chunk-NZU7W256.cjs +0 -29
  157. package/dist/chunk-NZU7W256.cjs.map +0 -1
  158. package/dist/chunk-UF2N5L63.cjs.map +0 -1
  159. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  160. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  161. package/dist/libav-http-reader-NQJVY273.js +0 -3
  162. package/dist/source-CN43EI7Z.cjs +0 -28
  163. package/dist/source-FFZ7TW2B.js +0 -3
  164. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  165. 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>();
@@ -39,6 +41,15 @@ export class UnifiedPlayer {
39
41
  // source (e.g. <avbridge-video>).
40
42
  private endedListener: (() => void) | null = null;
41
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
+
42
53
  // Serializes escalation / setStrategy calls
43
54
  private switchingPromise: Promise<void> = Promise.resolve();
44
55
 
@@ -46,13 +57,23 @@ export class UnifiedPlayer {
46
57
  // Revoked at destroy() so repeated source swaps don't leak.
47
58
  private subtitleResources = new SubtitleResourceBag();
48
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
+
49
65
  /**
50
66
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
51
67
  */
52
68
  private constructor(
53
69
  private readonly options: CreatePlayerOptions,
54
70
  private readonly registry: PluginRegistry,
55
- ) {}
71
+ ) {
72
+ const { requestInit, fetchFn } = options;
73
+ if (requestInit || fetchFn) {
74
+ this.transport = { requestInit, fetchFn };
75
+ }
76
+ }
56
77
 
57
78
  static async create(options: CreatePlayerOptions): Promise<UnifiedPlayer> {
58
79
  const registry = new PluginRegistry();
@@ -74,7 +95,7 @@ export class UnifiedPlayer {
74
95
  const bootstrapStart = performance.now();
75
96
  try {
76
97
  dbg.info("bootstrap", "start");
77
- 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));
78
99
  dbg.info("probe",
79
100
  `container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} ` +
80
101
  `audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`,
@@ -126,21 +147,21 @@ export class UnifiedPlayer {
126
147
  // Try the primary strategy, falling through the chain on failure
127
148
  await this.startSession(decision.strategy, decision.reason);
128
149
 
129
- // Apply subtitles for non-canvas strategies. Per-track failures are
130
- // caught inside attachSubtitleTracks and logged via console.warn
131
- // subtitles are not load-bearing, so a bad sidecar must not break
132
- // bootstrap.
133
- if (this.session!.strategy !== "fallback" && this.session!.strategy !== "hybrid") {
134
- await attachSubtitleTracks(
135
- this.options.target,
136
- ctx.subtitleTracks,
137
- this.subtitleResources,
138
- (err, track) => {
139
- // eslint-disable-next-line no-console
140
- console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
141
- },
142
- );
143
- }
150
+ // Apply subtitles for all strategies. Native/remux render them via
151
+ // the inner <video>'s native text-track engine. Hybrid/fallback
152
+ // hide the <video> and render cues into the canvas overlay — see
153
+ // each session's SubtitleOverlay wiring. The <track> elements are
154
+ // attached in both cases so cues are parsed by the browser.
155
+ await attachSubtitleTracks(
156
+ this.options.target,
157
+ ctx.subtitleTracks,
158
+ this.subtitleResources,
159
+ (err, track) => {
160
+ // eslint-disable-next-line no-console
161
+ console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
162
+ },
163
+ this.transport,
164
+ );
144
165
 
145
166
  this.emitter.emitSticky("tracks", {
146
167
  video: ctx.videoTracks,
@@ -151,6 +172,15 @@ export class UnifiedPlayer {
151
172
  this.startTimeupdateLoop();
152
173
  this.endedListener = () => this.emitter.emit("ended", undefined);
153
174
  this.options.target.addEventListener("ended", this.endedListener);
175
+
176
+ // Auto-pause on background tab (unless explicitly opted out).
177
+ // Chrome throttles rAF and setTimeout in hidden tabs, so playback
178
+ // degrades anyway — better to pause cleanly and resume on return.
179
+ if (this.options.backgroundBehavior !== "continue" && typeof document !== "undefined") {
180
+ this.visibilityListener = () => this.onVisibilityChange();
181
+ document.addEventListener("visibilitychange", this.visibilityListener);
182
+ }
183
+
154
184
  this.emitter.emitSticky("ready", undefined);
155
185
  const bootstrapElapsed = performance.now() - bootstrapStart;
156
186
  dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
@@ -181,7 +211,7 @@ export class UnifiedPlayer {
181
211
  }
182
212
 
183
213
  try {
184
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
214
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
185
215
  } catch (err) {
186
216
  // Try the fallback chain
187
217
  const chain = this.classification?.fallbackChain;
@@ -275,7 +305,7 @@ export class UnifiedPlayer {
275
305
  }
276
306
 
277
307
  try {
278
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
308
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
279
309
  } catch (err) {
280
310
  const msg = err instanceof Error ? err.message : String(err);
281
311
  errors.push(`${nextStrategy}: ${msg}`);
@@ -302,8 +332,10 @@ export class UnifiedPlayer {
302
332
  }
303
333
 
304
334
  // Chain exhausted with no working strategy.
305
- this.emitter.emit("error", new Error(
306
- `all fallback strategies failed: ${errors.join("; ")}`,
335
+ this.emitter.emit("error", new AvbridgeError(
336
+ ERR_ALL_STRATEGIES_EXHAUSTED,
337
+ `All playback strategies failed: ${errors.join("; ")}`,
338
+ "This file may require a codec or container that isn't available in this browser. Try the fallback strategy or check browser codec support.",
307
339
  ));
308
340
  }
309
341
 
@@ -366,7 +398,7 @@ export class UnifiedPlayer {
366
398
 
367
399
  /** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
368
400
  async setStrategy(strategy: StrategyName, reason?: string): Promise<void> {
369
- if (!this.mediaContext) throw new Error("player not ready");
401
+ 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.");
370
402
  if (this.session?.strategy === strategy) return;
371
403
 
372
404
  this.switchingPromise = this.switchingPromise.then(() =>
@@ -398,7 +430,7 @@ export class UnifiedPlayer {
398
430
  const plugin = this.registry.findFor(this.mediaContext!, strategy);
399
431
  if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
400
432
 
401
- this.session = await plugin.execute(this.mediaContext!, this.options.target);
433
+ this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
402
434
 
403
435
  this.emitter.emitSticky("strategy", {
404
436
  strategy,
@@ -441,30 +473,62 @@ export class UnifiedPlayer {
441
473
 
442
474
  /** Begin or resume playback. Throws if the player is not ready. */
443
475
  async play(): Promise<void> {
444
- if (!this.session) throw new Error("player not ready");
476
+ 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.");
477
+ this.userIntent = "play";
478
+ this.autoPausedForVisibility = false;
445
479
  await this.session.play();
446
480
  }
447
481
 
448
482
  /** Pause playback. No-op if the player is not ready or already paused. */
449
483
  pause(): void {
484
+ this.userIntent = "pause";
485
+ this.autoPausedForVisibility = false;
450
486
  this.session?.pause();
451
487
  }
452
488
 
489
+ /**
490
+ * Handle browser tab visibility changes. On hide: pause if the user
491
+ * had been playing. On show: resume if we were the one who paused.
492
+ * Skips when `backgroundBehavior: "continue"` is set (listener isn't
493
+ * installed in that case).
494
+ */
495
+ private onVisibilityChange(): void {
496
+ if (!this.session) return;
497
+ const action = decideVisibilityAction({
498
+ hidden: document.hidden,
499
+ userIntent: this.userIntent,
500
+ sessionIsPlaying: !this.options.target.paused,
501
+ autoPausedForVisibility: this.autoPausedForVisibility,
502
+ });
503
+ if (action === "pause") {
504
+ this.autoPausedForVisibility = true;
505
+ dbg.info("visibility", "tab hidden — auto-paused");
506
+ this.session.pause();
507
+ } else if (action === "resume") {
508
+ this.autoPausedForVisibility = false;
509
+ dbg.info("visibility", "tab visible — auto-resuming");
510
+ void this.session.play().catch((err) => {
511
+ // eslint-disable-next-line no-console
512
+ console.warn("[avbridge] auto-resume after tab return failed:", err);
513
+ });
514
+ }
515
+ }
516
+
453
517
  /** Seek to the given time in seconds. Throws if the player is not ready. */
454
518
  async seek(time: number): Promise<void> {
455
- if (!this.session) throw new Error("player not ready");
519
+ 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.");
456
520
  await this.session.seek(time);
457
521
  }
458
522
 
459
523
  /** Switch the active audio track by track ID. Throws if the player is not ready. */
460
524
  async setAudioTrack(id: number): Promise<void> {
461
- if (!this.session) throw new Error("player not ready");
525
+ 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.");
462
526
  await this.session.setAudioTrack(id);
463
527
  }
464
528
 
465
529
  /** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
466
530
  async setSubtitleTrack(id: number | null): Promise<void> {
467
- if (!this.session) throw new Error("player not ready");
531
+ 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.");
468
532
  await this.session.setSubtitleTrack(id);
469
533
  }
470
534
 
@@ -500,6 +564,10 @@ export class UnifiedPlayer {
500
564
  this.options.target.removeEventListener("ended", this.endedListener);
501
565
  this.endedListener = null;
502
566
  }
567
+ if (this.visibilityListener) {
568
+ document.removeEventListener("visibilitychange", this.visibilityListener);
569
+ this.visibilityListener = null;
570
+ }
503
571
  if (this.session) {
504
572
  await this.session.destroy();
505
573
  this.session = null;
@@ -515,6 +583,29 @@ export async function createPlayer(options: CreatePlayerOptions): Promise<Unifie
515
583
  return UnifiedPlayer.create(options);
516
584
  }
517
585
 
586
+ /**
587
+ * Pure decision function for visibility-change handling. Separated from
588
+ * the class method so it can be unit-tested without a full player
589
+ * instance.
590
+ *
591
+ * @internal — exported for unit tests; not part of the public API.
592
+ */
593
+ export function decideVisibilityAction(state: {
594
+ hidden: boolean;
595
+ userIntent: "play" | "pause";
596
+ sessionIsPlaying: boolean;
597
+ autoPausedForVisibility: boolean;
598
+ }): "pause" | "resume" | "noop" {
599
+ if (state.hidden) {
600
+ // Tab hidden: pause if user had been playing and session is active
601
+ if (state.userIntent === "play" && state.sessionIsPlaying) return "pause";
602
+ return "noop";
603
+ }
604
+ // Tab visible: resume only if we're the one who paused
605
+ if (state.autoPausedForVisibility) return "resume";
606
+ return "noop";
607
+ }
608
+
518
609
  /**
519
610
  * Build a synthetic classification for an explicit `initialStrategy` override.
520
611
  * The `class` is derived from the chosen strategy so diagnostics and any
@@ -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;