avbridge 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-EQE6AR75.cjs} +4 -4
  3. package/dist/{avi-2ILLBNPQ.cjs.map → avi-EQE6AR75.cjs.map} +1 -1
  4. package/dist/{avi-RWWPN2PR.js → avi-NNHH4AAA.js} +3 -3
  5. package/dist/{avi-JXU4GQL2.js.map → avi-NNHH4AAA.js.map} +1 -1
  6. package/dist/{avi-JXU4GQL2.js → avi-S7EY54YA.js} +3 -3
  7. package/dist/{avi-RWWPN2PR.js.map → avi-S7EY54YA.js.map} +1 -1
  8. package/dist/{avi-2ILLBNPQ.cjs → avi-Y3N325WZ.cjs} +4 -4
  9. package/dist/{avi-B5CQYB7L.cjs.map → avi-Y3N325WZ.cjs.map} +1 -1
  10. package/dist/{chunk-GYIJU44C.js → chunk-2LNXMGT6.js} +5 -5
  11. package/dist/{chunk-GYIJU44C.js.map → chunk-2LNXMGT6.js.map} +1 -1
  12. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  13. package/dist/chunk-3AI5WFFN.js.map +1 -0
  14. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  15. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-5Y5BTB5D.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-5Y5BTB5D.js.map} +1 -1
  18. package/dist/{chunk-NQULEIA3.cjs → chunk-7EF4VTUS.cjs} +36 -28
  19. package/dist/chunk-7EF4VTUS.cjs.map +1 -0
  20. package/dist/{chunk-5KVLE6YI.js → chunk-EDDWAN2L.js} +3 -2
  21. package/dist/chunk-EDDWAN2L.js.map +1 -0
  22. package/dist/{chunk-OTFS7DC4.cjs → chunk-GJBNLPGI.cjs} +14 -14
  23. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-GJBNLPGI.cjs.map} +1 -1
  24. package/dist/{chunk-BYGZN4Z5.cjs → chunk-HBHSUGNI.cjs} +5 -5
  25. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-HBHSUGNI.cjs.map} +1 -1
  26. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  27. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  28. package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
  29. package/dist/chunk-WRKO6Q42.cjs.map +1 -0
  30. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  31. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  32. package/dist/{chunk-3GKM5DFM.js → chunk-Z26PXRUY.js} +18 -10
  33. package/dist/chunk-Z26PXRUY.js.map +1 -0
  34. package/dist/element-browser.js +65 -19
  35. package/dist/element-browser.js.map +1 -1
  36. package/dist/element.cjs +21 -8
  37. package/dist/element.cjs.map +1 -1
  38. package/dist/element.d.cts +1 -1
  39. package/dist/element.d.ts +1 -1
  40. package/dist/element.js +20 -7
  41. package/dist/element.js.map +1 -1
  42. package/dist/index.cjs +23 -23
  43. package/dist/index.d.cts +2 -2
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +10 -10
  46. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  47. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  48. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  49. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  50. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  51. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  52. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  53. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  54. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  55. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  56. package/dist/player.cjs +166 -33
  57. package/dist/player.cjs.map +1 -1
  58. package/dist/player.d.cts +36 -0
  59. package/dist/player.d.ts +36 -0
  60. package/dist/player.js +162 -29
  61. package/dist/player.js.map +1 -1
  62. package/dist/remux-7TA4FKTY.js +10 -0
  63. package/dist/{remux-56V7LDAD.js.map → remux-7TA4FKTY.js.map} +1 -1
  64. package/dist/remux-VPKCLHHM.cjs +35 -0
  65. package/dist/{remux-KUS5GIL6.cjs.map → remux-VPKCLHHM.cjs.map} +1 -1
  66. package/dist/subtitles-5H24MEBJ.js +4 -0
  67. package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
  68. package/dist/subtitles-HMVGWTU2.cjs +29 -0
  69. package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
  70. package/package.json +1 -1
  71. package/src/element/avbridge-player.ts +128 -18
  72. package/src/element/avbridge-subtitles.ts +273 -0
  73. package/src/element/avbridge-video.ts +21 -1
  74. package/src/element/player-styles.ts +13 -1
  75. package/src/player.ts +3 -3
  76. package/src/strategies/fallback/audio-output.ts +10 -0
  77. package/src/subtitles/index.ts +2 -0
  78. package/src/types.ts +15 -0
  79. package/src/util/libav-http-reader.ts +58 -19
  80. package/dist/chunk-3GKM5DFM.js.map +0 -1
  81. package/dist/chunk-5KVLE6YI.js.map +0 -1
  82. package/dist/chunk-DCSOQH2N.js.map +0 -1
  83. package/dist/chunk-NQULEIA3.cjs.map +0 -1
  84. package/dist/chunk-S4WAZC2T.cjs.map +0 -1
  85. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  86. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  87. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  88. package/dist/remux-56V7LDAD.js +0 -10
  89. package/dist/remux-KUS5GIL6.cjs +0 -35
  90. package/dist/subtitles-4T74JRGT.js +0 -4
  91. package/dist/subtitles-QUH4LPI4.cjs +0 -29
@@ -219,6 +219,14 @@ interface CreatePlayerOptions {
219
219
  * for interceptors, logging, or environments without a global fetch.
220
220
  */
221
221
  fetchFn?: FetchFn;
222
+ /**
223
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
224
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
225
+ * app plays seek-heavy legacy-container media from URLs — hot regions
226
+ * (header/moov, tail index, current window) stay resident instead of
227
+ * being re-fetched on every bounce.
228
+ */
229
+ cacheBytes?: number;
222
230
  }
223
231
  /** Signature-compatible with `globalThis.fetch`. */
224
232
  type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -226,6 +234,13 @@ type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respons
226
234
  interface TransportConfig {
227
235
  requestInit?: RequestInit;
228
236
  fetchFn?: FetchFn;
237
+ /**
238
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
239
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
240
+ * values help seek-heavy network playback keep hot regions
241
+ * (header/moov, tail index, current read) resident.
242
+ */
243
+ cacheBytes?: number;
229
244
  }
230
245
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
231
246
  interface PlayerEventMap {
@@ -219,6 +219,14 @@ interface CreatePlayerOptions {
219
219
  * for interceptors, logging, or environments without a global fetch.
220
220
  */
221
221
  fetchFn?: FetchFn;
222
+ /**
223
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
224
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
225
+ * app plays seek-heavy legacy-container media from URLs — hot regions
226
+ * (header/moov, tail index, current window) stay resident instead of
227
+ * being re-fetched on every bounce.
228
+ */
229
+ cacheBytes?: number;
222
230
  }
223
231
  /** Signature-compatible with `globalThis.fetch`. */
224
232
  type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -226,6 +234,13 @@ type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respons
226
234
  interface TransportConfig {
227
235
  requestInit?: RequestInit;
228
236
  fetchFn?: FetchFn;
237
+ /**
238
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
239
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
240
+ * values help seek-heavy network playback keep hot regions
241
+ * (header/moov, tail index, current read) resident.
242
+ */
243
+ cacheBytes?: number;
229
244
  }
230
245
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
231
246
  interface PlayerEventMap {
package/dist/player.cjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  var chunk2XW2O3YI_cjs = require('./chunk-2XW2O3YI.cjs');
4
4
  var chunkNNVOHKXJ_cjs = require('./chunk-NNVOHKXJ.cjs');
5
- require('./chunk-Z33SBWL5.cjs');
6
- var chunkS4WAZC2T_cjs = require('./chunk-S4WAZC2T.cjs');
5
+ require('./chunk-YPZFGJV3.cjs');
6
+ var chunkWRKO6Q42_cjs = require('./chunk-WRKO6Q42.cjs');
7
7
  require('./chunk-QDJLQR53.cjs');
8
8
 
9
9
  // src/events.ts
@@ -239,7 +239,7 @@ async function probe(source, transport) {
239
239
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
240
240
  if (hasUnknownCodec) {
241
241
  try {
242
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
242
+ const { probeWithLibav } = await import('./avi-Y3N325WZ.cjs');
243
243
  return await probeWithLibav(normalized, sniffed);
244
244
  } catch {
245
245
  return result;
@@ -252,7 +252,7 @@ async function probe(source, transport) {
252
252
  mediabunnyErr.message
253
253
  );
254
254
  try {
255
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
255
+ const { probeWithLibav } = await import('./avi-Y3N325WZ.cjs');
256
256
  return await probeWithLibav(normalized, sniffed);
257
257
  } catch (libavErr) {
258
258
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -266,7 +266,7 @@ async function probe(source, transport) {
266
266
  }
267
267
  }
268
268
  try {
269
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
269
+ const { probeWithLibav } = await import('./avi-Y3N325WZ.cjs');
270
270
  return await probeWithLibav(normalized, sniffed);
271
271
  } catch (err) {
272
272
  const inner = err instanceof Error ? err.message : String(err);
@@ -1286,7 +1286,7 @@ var VideoRenderer = class {
1286
1286
  }
1287
1287
  target.style.visibility = "hidden";
1288
1288
  const overlayParent = parent instanceof HTMLElement ? parent : document.body;
1289
- this.subtitleOverlay = new chunkS4WAZC2T_cjs.SubtitleOverlay(overlayParent);
1289
+ this.subtitleOverlay = new chunkWRKO6Q42_cjs.SubtitleOverlay(overlayParent);
1290
1290
  this.watchTextTracks(target);
1291
1291
  const ctx = this.canvas.getContext("2d");
1292
1292
  if (!ctx) throw new Error("video renderer: failed to acquire 2D context");
@@ -1751,6 +1751,10 @@ var AudioOutput = class {
1751
1751
  if (this.ctx.state === "suspended") {
1752
1752
  await this.ctx.resume();
1753
1753
  }
1754
+ try {
1755
+ this.gain.connect(this.ctx.destination);
1756
+ } catch {
1757
+ }
1754
1758
  if (this.state === "paused") {
1755
1759
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1756
1760
  this.state = "playing";
@@ -1777,6 +1781,10 @@ var AudioOutput = class {
1777
1781
  this.mediaTimeOfAnchor = this.now();
1778
1782
  this.state = "paused";
1779
1783
  if (this.noAudio) return;
1784
+ try {
1785
+ this.gain.disconnect();
1786
+ } catch {
1787
+ }
1780
1788
  if (this.ctx.state === "running") {
1781
1789
  await this.ctx.suspend();
1782
1790
  }
@@ -2015,7 +2023,7 @@ async function startHybridDecoder(opts) {
2015
2023
  const variant = pickLibavVariant(opts.context);
2016
2024
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
2017
2025
  const bridge = await loadBridge();
2018
- const { prepareLibavInput } = await import('./libav-http-reader-AZLE7YFS.cjs');
2026
+ const { prepareLibavInput } = await import('./libav-http-reader-Q356EO2K.cjs');
2019
2027
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2020
2028
  const readPkt = await libav.av_packet_alloc();
2021
2029
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2685,7 +2693,7 @@ async function startDecoder(opts) {
2685
2693
  const variant = "avbridge";
2686
2694
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
2687
2695
  const bridge = await loadBridge2();
2688
- const { prepareLibavInput } = await import('./libav-http-reader-AZLE7YFS.cjs');
2696
+ const { prepareLibavInput } = await import('./libav-http-reader-Q356EO2K.cjs');
2689
2697
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2690
2698
  const readPkt = await libav.av_packet_alloc();
2691
2699
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -3414,9 +3422,9 @@ var UnifiedPlayer = class _UnifiedPlayer {
3414
3422
  constructor(options, registry) {
3415
3423
  this.options = options;
3416
3424
  this.registry = registry;
3417
- const { requestInit, fetchFn } = options;
3418
- if (requestInit || fetchFn) {
3419
- this.transport = { requestInit, fetchFn };
3425
+ const { requestInit, fetchFn, cacheBytes } = options;
3426
+ if (requestInit || fetchFn || cacheBytes !== void 0) {
3427
+ this.transport = { requestInit, fetchFn, cacheBytes };
3420
3428
  }
3421
3429
  }
3422
3430
  options;
@@ -3456,7 +3464,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3456
3464
  switchingPromise = Promise.resolve();
3457
3465
  // Owns blob URLs created during sidecar discovery + SRT->VTT conversion.
3458
3466
  // Revoked at destroy() so repeated source swaps don't leak.
3459
- subtitleResources = new chunkS4WAZC2T_cjs.SubtitleResourceBag();
3467
+ subtitleResources = new chunkWRKO6Q42_cjs.SubtitleResourceBag();
3460
3468
  // Transport config extracted from CreatePlayerOptions. Threaded to probe,
3461
3469
  // subtitle fetches, and strategy session creators. Not stored on MediaContext
3462
3470
  // because it's runtime config, not media analysis.
@@ -3502,7 +3510,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3502
3510
  }
3503
3511
  }
3504
3512
  if (this.options.directory && this.options.source instanceof File) {
3505
- const found = await chunkS4WAZC2T_cjs.discoverSidecars(this.options.source, this.options.directory);
3513
+ const found = await chunkWRKO6Q42_cjs.discoverSidecars(this.options.source, this.options.directory);
3506
3514
  for (const s of found) {
3507
3515
  this.subtitleResources.track(s.url);
3508
3516
  ctx.subtitleTracks.push({
@@ -3525,7 +3533,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3525
3533
  reason: decision.reason
3526
3534
  });
3527
3535
  await this.startSession(decision.strategy, decision.reason);
3528
- await chunkS4WAZC2T_cjs.attachSubtitleTracks(
3536
+ await chunkWRKO6Q42_cjs.attachSubtitleTracks(
3529
3537
  this.options.target,
3530
3538
  ctx.subtitleTracks,
3531
3539
  this.subtitleResources,
@@ -4567,7 +4575,7 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4567
4575
  * strategies pick up the new track via their textTracks watcher.
4568
4576
  */
4569
4577
  async addSubtitle(subtitle) {
4570
- const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-QUH4LPI4.cjs');
4578
+ const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-HMVGWTU2.cjs');
4571
4579
  const format = subtitle.format ?? (subtitle.url.endsWith(".srt") ? "srt" : "vtt");
4572
4580
  const track = {
4573
4581
  id: this._subtitleTracks.length,
@@ -4576,14 +4584,27 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4576
4584
  sidecarUrl: subtitle.url
4577
4585
  };
4578
4586
  this._subtitleTracks.push(track);
4587
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
4579
4588
  await attachSubtitleTracks2(
4580
4589
  this._videoEl,
4581
4590
  this._subtitleTracks,
4582
4591
  void 0,
4583
4592
  (err, t) => {
4584
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
4593
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
4585
4594
  }
4586
4595
  );
4596
+ const textTracks = this._videoEl.textTracks;
4597
+ for (let i = 0; i < textTracks.length; i++) {
4598
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
4599
+ textTracks[i].mode = "showing";
4600
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
4601
+ break;
4602
+ }
4603
+ }
4604
+ this._dispatch("trackschange", {
4605
+ audioTracks: this._audioTracks,
4606
+ subtitleTracks: this.subtitleTracks
4607
+ });
4587
4608
  }
4588
4609
  /**
4589
4610
  * Disable the automatic `screen.orientation.lock()` that runs on
@@ -5014,6 +5035,12 @@ var PLAYER_STYLES = (
5014
5035
  display: flex;
5015
5036
  align-items: center;
5016
5037
  cursor: pointer;
5038
+ /* Claim all touch gestures on the seek bar. Without this, Android
5039
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
5040
+ * scroll candidates and cancel pointermove once the gesture
5041
+ * resolves, breaking scrub. touch-action must be set in CSS \u2014
5042
+ * preventDefault() on pointerdown is too late. */
5043
+ touch-action: none;
5017
5044
  }
5018
5045
 
5019
5046
  .avp-seek-track {
@@ -5031,7 +5058,13 @@ var PLAYER_STYLES = (
5031
5058
 
5032
5059
  .avp-seek-buffered {
5033
5060
  position: absolute;
5034
- left: 0;
5061
+ inset: 0;
5062
+ pointer-events: none;
5063
+ }
5064
+
5065
+ .avp-seek-buffered-range {
5066
+ position: absolute;
5067
+ top: 0;
5035
5068
  height: 100%;
5036
5069
  background: rgba(255, 255, 255, 0.35);
5037
5070
  border-radius: inherit;
@@ -5397,7 +5430,7 @@ function formatTime(sec) {
5397
5430
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
5398
5431
  }
5399
5432
  var PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
5400
- var CONTROLS_HIDE_MS = 3e3;
5433
+ var DEFAULT_CONTROLS_HIDE_MS = 3e3;
5401
5434
  var FORWARDED_EVENTS = [
5402
5435
  "ready",
5403
5436
  "error",
@@ -5440,7 +5473,7 @@ var PROXY_ATTRIBUTES = [
5440
5473
  ];
5441
5474
  var PLAYER_ATTRIBUTES = ["show-fit"];
5442
5475
  var FIT_MODES = ["contain", "cover", "fill"];
5443
- var AvbridgePlayerElement = class extends HTMLElement {
5476
+ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5444
5477
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5445
5478
  // ── Internal DOM refs ──────────────────────────────────────────────────
5446
5479
  _video;
@@ -5468,6 +5501,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5468
5501
  _state = "idle";
5469
5502
  _controlsTimer = null;
5470
5503
  _settingsOpen = false;
5504
+ _activeAudioTrackId = null;
5505
+ _activeSubtitleTrackId = null;
5471
5506
  _userSeeking = false;
5472
5507
  _holdTimer = null;
5473
5508
  _holdSpeedActive = false;
@@ -5589,6 +5624,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5589
5624
  on(this._video, "ended", () => this._setState("ended"));
5590
5625
  on(this._video, "error", () => this._setState("error"));
5591
5626
  on(this._video, "timeupdate", () => this._updateTime());
5627
+ on(this._video, "progress", () => this._updateBuffered());
5592
5628
  on(this._video, "volumechange", () => this._updateVolume());
5593
5629
  on(this._video, "trackschange", () => this._buildSettingsMenu());
5594
5630
  on(this._video, "durationchange", () => {
@@ -5731,6 +5767,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5731
5767
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5732
5768
  return frac * (this._video.duration || 0);
5733
5769
  }
5770
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5771
+ * preview-only). On narrow bars precise positioning is hard, so
5772
+ * immediate video feedback is more useful than a time tooltip. */
5773
+ static SCRUB_WIDTH_THRESHOLD = 400;
5734
5774
  _onSeekPointerDown(e) {
5735
5775
  if (e.button !== 0 && e.pointerType === "mouse") return;
5736
5776
  e.preventDefault();
@@ -5738,15 +5778,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5738
5778
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5739
5779
  seekBar.setPointerCapture(e.pointerId);
5740
5780
  seekBar.setAttribute("data-seeking", "");
5781
+ const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5782
+ let lastScrubCommit = 0;
5741
5783
  const initial = this._timeFromSeekPointer(e.clientX);
5742
5784
  this._seekInput.value = String(initial);
5743
5785
  this._onSeekInput();
5744
5786
  this._updateSeekTooltip(e.clientX);
5787
+ if (scrubMode) this._onSeekCommit();
5745
5788
  const onMove = (ev) => {
5746
5789
  const t = this._timeFromSeekPointer(ev.clientX);
5747
5790
  this._seekInput.value = String(t);
5748
5791
  this._onSeekInput();
5749
5792
  this._updateSeekTooltip(ev.clientX);
5793
+ if (scrubMode) {
5794
+ const now = performance.now();
5795
+ if (now - lastScrubCommit > 250) {
5796
+ lastScrubCommit = now;
5797
+ this._onSeekCommit();
5798
+ this._userSeeking = true;
5799
+ }
5800
+ }
5750
5801
  };
5751
5802
  const onUp = (ev) => {
5752
5803
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -5790,13 +5841,45 @@ var AvbridgePlayerElement = class extends HTMLElement {
5790
5841
  this._seekInput.value = String(t);
5791
5842
  this._updateSeekVisuals(t);
5792
5843
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
5844
+ this._updateBuffered();
5845
+ }
5846
+ /**
5847
+ * Render every buffered range as its own segment so gaps (common on MSE
5848
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
5849
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
5850
+ */
5851
+ _updateBuffered() {
5852
+ const d = this._video.duration;
5853
+ if (!(d > 0)) return;
5854
+ let buf;
5793
5855
  try {
5794
- const buf = this._video.buffered;
5795
- if (buf && buf.length > 0 && d > 0) {
5796
- const end = buf.end(buf.length - 1);
5797
- this._seekBuffered.style.width = `${end / d * 100}%`;
5798
- }
5856
+ buf = this._video.buffered;
5799
5857
  } catch {
5858
+ return;
5859
+ }
5860
+ const count = buf ? buf.length : 0;
5861
+ const host = this._seekBuffered;
5862
+ while (host.childElementCount > count) host.lastElementChild.remove();
5863
+ while (host.childElementCount < count) {
5864
+ const seg = document.createElement("div");
5865
+ seg.className = "avp-seek-buffered-range";
5866
+ host.appendChild(seg);
5867
+ }
5868
+ for (let i = 0; i < count; i++) {
5869
+ let start;
5870
+ let end;
5871
+ try {
5872
+ start = buf.start(i);
5873
+ end = buf.end(i);
5874
+ } catch {
5875
+ continue;
5876
+ }
5877
+ const s = Math.max(0, start);
5878
+ const e = Math.min(d, end);
5879
+ if (e <= s) continue;
5880
+ const seg = host.children[i];
5881
+ seg.style.left = `${s / d * 100}%`;
5882
+ seg.style.width = `${(e - s) / d * 100}%`;
5800
5883
  }
5801
5884
  }
5802
5885
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -5847,19 +5930,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
5847
5930
  sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5848
5931
  const audios = this._video.audioTracks ?? [];
5849
5932
  if (audios.length > 1) {
5933
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
5934
+ const activeAudio = audios.find((t) => t.id === activeAudioId) ?? audios[0];
5935
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
5850
5936
  let audioOpts = "";
5851
5937
  for (const t of audios) {
5852
- audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5938
+ const sel = t.id === activeAudioId ? " selected" : "";
5939
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5853
5940
  }
5854
- sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
5941
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
5855
5942
  }
5856
5943
  const subs = this._video.subtitleTracks ?? [];
5857
5944
  if (subs.length > 0) {
5858
- let subOpts = `<option value="-1" selected>Off</option>`;
5945
+ const activeSubId = this._activeSubtitleTrackId;
5946
+ const activeSub = activeSubId != null ? subs.find((t) => t.id === activeSubId) : null;
5947
+ const subValue = activeSub ? activeSub.language ?? `Track ${activeSub.id}` : "Off";
5948
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
5859
5949
  for (const t of subs) {
5860
- subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5950
+ const sel = t.id === activeSubId ? " selected" : "";
5951
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5861
5952
  }
5862
- sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
5953
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
5863
5954
  }
5864
5955
  if (this.hasAttribute("show-fit")) {
5865
5956
  const currentFit = this._video.fit ?? "contain";
@@ -5899,11 +5990,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
5899
5990
  this._video.playbackRate = Number(val);
5900
5991
  break;
5901
5992
  case "audio":
5993
+ this._activeAudioTrackId = Number(val);
5902
5994
  void this._video.setAudioTrack(Number(val));
5903
5995
  break;
5904
- case "subtitle":
5905
- void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
5996
+ case "subtitle": {
5997
+ const subId = Number(val);
5998
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
5999
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
5906
6000
  break;
6001
+ }
5907
6002
  case "fit":
5908
6003
  this.setAttribute("fit", val);
5909
6004
  break;
@@ -5998,16 +6093,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5998
6093
  _showControls() {
5999
6094
  this.showControls();
6000
6095
  }
6001
- _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
6096
+ _scheduleHide(durationMs) {
6097
+ const ms = durationMs ?? this._getControlsTimeout();
6002
6098
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
6003
6099
  if (this._state !== "playing" && this._state !== "buffering") return;
6004
6100
  if (this._settingsOpen) return;
6101
+ if (ms <= 0) return;
6005
6102
  this._controlsTimer = setTimeout(() => {
6006
6103
  if (this._state === "playing") {
6007
6104
  this.setAttribute("data-controls-hidden", "");
6008
6105
  this._toolbarTop.setAttribute("data-visible", "false");
6009
6106
  }
6010
- }, durationMs);
6107
+ }, ms);
6108
+ }
6109
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
6110
+ * Unset = default 3000ms. */
6111
+ _getControlsTimeout() {
6112
+ const attr = this.getAttribute("controls-timeout");
6113
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
6114
+ const n = Number(attr);
6115
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
6011
6116
  }
6012
6117
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
6013
6118
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -6019,6 +6124,9 @@ var AvbridgePlayerElement = class extends HTMLElement {
6019
6124
  // it's treated as a double-click and the single-click action is cancelled.
6020
6125
  /** Track whether the last interaction was touch so click handler can skip. */
6021
6126
  _lastPointerTypeWasTouch = false;
6127
+ /** True for ~50ms after a touch double-tap was handled, so the
6128
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
6129
+ _touchDoubleTapConsumed = false;
6022
6130
  /** True if the event's composed path passes through consumer-slotted
6023
6131
  * content (toolbar or content-overlay). Slotted content lives in the
6024
6132
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -6052,6 +6160,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
6052
6160
  _onContainerDblClick(e) {
6053
6161
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
6054
6162
  if (this._isSlottedContentEvent(e)) return;
6163
+ if (this._touchDoubleTapConsumed) return;
6055
6164
  if (this._tapTimer) {
6056
6165
  clearTimeout(this._tapTimer);
6057
6166
  this._tapTimer = null;
@@ -6093,6 +6202,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
6093
6202
  } else {
6094
6203
  this._toggleFullscreen();
6095
6204
  }
6205
+ this._touchDoubleTapConsumed = true;
6206
+ setTimeout(() => {
6207
+ this._touchDoubleTapConsumed = false;
6208
+ }, 100);
6096
6209
  this._lastTapTime = 0;
6097
6210
  return;
6098
6211
  }
@@ -6126,6 +6239,13 @@ var AvbridgePlayerElement = class extends HTMLElement {
6126
6239
  this._video.currentTime = Math.max(0, this._video.currentTime + delta);
6127
6240
  }
6128
6241
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
6242
+ /** Duration of one frame in seconds, derived from diagnostics fps or
6243
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
6244
+ _frameDuration() {
6245
+ const diag = this._video.getDiagnostics();
6246
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
6247
+ return 1 / fps;
6248
+ }
6129
6249
  _onKeydown(e) {
6130
6250
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
6131
6251
  switch (e.key) {
@@ -6170,6 +6290,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
6170
6290
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
6171
6291
  this._buildSettingsMenu();
6172
6292
  break;
6293
+ case ",":
6294
+ e.preventDefault();
6295
+ if (!this._video.paused) this._video.pause();
6296
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
6297
+ break;
6298
+ case ".":
6299
+ e.preventDefault();
6300
+ if (!this._video.paused) this._video.pause();
6301
+ this._video.currentTime = Math.min(
6302
+ this._video.duration || 0,
6303
+ this._video.currentTime + this._frameDuration()
6304
+ );
6305
+ break;
6173
6306
  case "Escape":
6174
6307
  if (this._settingsOpen) {
6175
6308
  e.preventDefault();