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
package/dist/player.d.cts CHANGED
@@ -209,6 +209,14 @@ interface CreatePlayerOptions {
209
209
  * for interceptors, logging, or environments without a global fetch.
210
210
  */
211
211
  fetchFn?: FetchFn;
212
+ /**
213
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
214
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
215
+ * app plays seek-heavy legacy-container media from URLs — hot regions
216
+ * (header/moov, tail index, current window) stay resident instead of
217
+ * being re-fetched on every bounce.
218
+ */
219
+ cacheBytes?: number;
212
220
  }
213
221
  /** Signature-compatible with `globalThis.fetch`. */
214
222
  type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -216,6 +224,13 @@ type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respons
216
224
  interface TransportConfig {
217
225
  requestInit?: RequestInit;
218
226
  fetchFn?: FetchFn;
227
+ /**
228
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
229
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
230
+ * values help seek-heavy network playback keep hot regions
231
+ * (header/moov, tail index, current read) resident.
232
+ */
233
+ cacheBytes?: number;
219
234
  }
220
235
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
221
236
  interface PlayerEventMap {
@@ -345,6 +360,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
345
360
  private _state;
346
361
  private _controlsTimer;
347
362
  private _settingsOpen;
363
+ private _activeAudioTrackId;
364
+ private _activeSubtitleTrackId;
348
365
  private _userSeeking;
349
366
  private _holdTimer;
350
367
  private _holdSpeedActive;
@@ -369,11 +386,21 @@ declare class AvbridgePlayerElement extends HTMLElement {
369
386
  private _onSeekCommit;
370
387
  /** Linear click-to-time mapping across the full track width (no edge clamping). */
371
388
  private _timeFromSeekPointer;
389
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
390
+ * preview-only). On narrow bars precise positioning is hard, so
391
+ * immediate video feedback is more useful than a time tooltip. */
392
+ private static readonly SCRUB_WIDTH_THRESHOLD;
372
393
  private _onSeekPointerDown;
373
394
  private _onSeekHover;
374
395
  private _updateSeekTooltip;
375
396
  private _updateSeekVisuals;
376
397
  private _updateTime;
398
+ /**
399
+ * Render every buffered range as its own segment so gaps (common on MSE
400
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
401
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
402
+ */
403
+ private _updateBuffered;
377
404
  private _toggleMute;
378
405
  private _updateVolume;
379
406
  private _toggleSettings;
@@ -399,8 +426,14 @@ declare class AvbridgePlayerElement extends HTMLElement {
399
426
  showControls(durationMs?: number): void;
400
427
  private _showControls;
401
428
  private _scheduleHide;
429
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
430
+ * Unset = default 3000ms. */
431
+ private _getControlsTimeout;
402
432
  /** Track whether the last interaction was touch so click handler can skip. */
403
433
  private _lastPointerTypeWasTouch;
434
+ /** True for ~50ms after a touch double-tap was handled, so the
435
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
436
+ private _touchDoubleTapConsumed;
404
437
  /** True if the event's composed path passes through consumer-slotted
405
438
  * content (toolbar or content-overlay). Slotted content lives in the
406
439
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -412,6 +445,9 @@ declare class AvbridgePlayerElement extends HTMLElement {
412
445
  private _onPointerUp;
413
446
  private _cancelHold;
414
447
  private _doDoubleTap;
448
+ /** Duration of one frame in seconds, derived from diagnostics fps or
449
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
450
+ private _frameDuration;
415
451
  private _onKeydown;
416
452
  private _clearTimers;
417
453
  get src(): string;
package/dist/player.d.ts CHANGED
@@ -209,6 +209,14 @@ interface CreatePlayerOptions {
209
209
  * for interceptors, logging, or environments without a global fetch.
210
210
  */
211
211
  fetchFn?: FetchFn;
212
+ /**
213
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
214
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
215
+ * app plays seek-heavy legacy-container media from URLs — hot regions
216
+ * (header/moov, tail index, current window) stay resident instead of
217
+ * being re-fetched on every bounce.
218
+ */
219
+ cacheBytes?: number;
212
220
  }
213
221
  /** Signature-compatible with `globalThis.fetch`. */
214
222
  type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -216,6 +224,13 @@ type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respons
216
224
  interface TransportConfig {
217
225
  requestInit?: RequestInit;
218
226
  fetchFn?: FetchFn;
227
+ /**
228
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
229
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
230
+ * values help seek-heavy network playback keep hot regions
231
+ * (header/moov, tail index, current read) resident.
232
+ */
233
+ cacheBytes?: number;
219
234
  }
220
235
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
221
236
  interface PlayerEventMap {
@@ -345,6 +360,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
345
360
  private _state;
346
361
  private _controlsTimer;
347
362
  private _settingsOpen;
363
+ private _activeAudioTrackId;
364
+ private _activeSubtitleTrackId;
348
365
  private _userSeeking;
349
366
  private _holdTimer;
350
367
  private _holdSpeedActive;
@@ -369,11 +386,21 @@ declare class AvbridgePlayerElement extends HTMLElement {
369
386
  private _onSeekCommit;
370
387
  /** Linear click-to-time mapping across the full track width (no edge clamping). */
371
388
  private _timeFromSeekPointer;
389
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
390
+ * preview-only). On narrow bars precise positioning is hard, so
391
+ * immediate video feedback is more useful than a time tooltip. */
392
+ private static readonly SCRUB_WIDTH_THRESHOLD;
372
393
  private _onSeekPointerDown;
373
394
  private _onSeekHover;
374
395
  private _updateSeekTooltip;
375
396
  private _updateSeekVisuals;
376
397
  private _updateTime;
398
+ /**
399
+ * Render every buffered range as its own segment so gaps (common on MSE
400
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
401
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
402
+ */
403
+ private _updateBuffered;
377
404
  private _toggleMute;
378
405
  private _updateVolume;
379
406
  private _toggleSettings;
@@ -399,8 +426,14 @@ declare class AvbridgePlayerElement extends HTMLElement {
399
426
  showControls(durationMs?: number): void;
400
427
  private _showControls;
401
428
  private _scheduleHide;
429
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
430
+ * Unset = default 3000ms. */
431
+ private _getControlsTimeout;
402
432
  /** Track whether the last interaction was touch so click handler can skip. */
403
433
  private _lastPointerTypeWasTouch;
434
+ /** True for ~50ms after a touch double-tap was handled, so the
435
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
436
+ private _touchDoubleTapConsumed;
404
437
  /** True if the event's composed path passes through consumer-slotted
405
438
  * content (toolbar or content-overlay). Slotted content lives in the
406
439
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -412,6 +445,9 @@ declare class AvbridgePlayerElement extends HTMLElement {
412
445
  private _onPointerUp;
413
446
  private _cancelHold;
414
447
  private _doDoubleTap;
448
+ /** Duration of one frame in seconds, derived from diagnostics fps or
449
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
450
+ private _frameDuration;
415
451
  private _onKeydown;
416
452
  private _clearTimers;
417
453
  get src(): string;
package/dist/player.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AvbridgeError, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, normalizeSource, sniffNormalizedSource, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_LIBAV_NOT_REACHABLE, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from './chunk-E76AMWI4.js';
2
2
  import { dbg, loadLibav } from './chunk-IAYKFGFG.js';
3
- import './chunk-DCSOQH2N.js';
4
- import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-5KVLE6YI.js';
3
+ import './chunk-3AI5WFFN.js';
4
+ import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-EDDWAN2L.js';
5
5
  import './chunk-LUFA47FP.js';
6
6
 
7
7
  // src/events.ts
@@ -237,7 +237,7 @@ async function probe(source, transport) {
237
237
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
238
238
  if (hasUnknownCodec) {
239
239
  try {
240
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
240
+ const { probeWithLibav } = await import('./avi-S7EY54YA.js');
241
241
  return await probeWithLibav(normalized, sniffed);
242
242
  } catch {
243
243
  return result;
@@ -250,7 +250,7 @@ async function probe(source, transport) {
250
250
  mediabunnyErr.message
251
251
  );
252
252
  try {
253
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
253
+ const { probeWithLibav } = await import('./avi-S7EY54YA.js');
254
254
  return await probeWithLibav(normalized, sniffed);
255
255
  } catch (libavErr) {
256
256
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -264,7 +264,7 @@ async function probe(source, transport) {
264
264
  }
265
265
  }
266
266
  try {
267
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
267
+ const { probeWithLibav } = await import('./avi-S7EY54YA.js');
268
268
  return await probeWithLibav(normalized, sniffed);
269
269
  } catch (err) {
270
270
  const inner = err instanceof Error ? err.message : String(err);
@@ -1749,6 +1749,10 @@ var AudioOutput = class {
1749
1749
  if (this.ctx.state === "suspended") {
1750
1750
  await this.ctx.resume();
1751
1751
  }
1752
+ try {
1753
+ this.gain.connect(this.ctx.destination);
1754
+ } catch {
1755
+ }
1752
1756
  if (this.state === "paused") {
1753
1757
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1754
1758
  this.state = "playing";
@@ -1775,6 +1779,10 @@ var AudioOutput = class {
1775
1779
  this.mediaTimeOfAnchor = this.now();
1776
1780
  this.state = "paused";
1777
1781
  if (this.noAudio) return;
1782
+ try {
1783
+ this.gain.disconnect();
1784
+ } catch {
1785
+ }
1778
1786
  if (this.ctx.state === "running") {
1779
1787
  await this.ctx.suspend();
1780
1788
  }
@@ -2013,7 +2021,7 @@ async function startHybridDecoder(opts) {
2013
2021
  const variant = pickLibavVariant(opts.context);
2014
2022
  const libav = await loadLibav(variant);
2015
2023
  const bridge = await loadBridge();
2016
- const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
2024
+ const { prepareLibavInput } = await import('./libav-http-reader-2S5HAHW4.js');
2017
2025
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2018
2026
  const readPkt = await libav.av_packet_alloc();
2019
2027
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2683,7 +2691,7 @@ async function startDecoder(opts) {
2683
2691
  const variant = "avbridge";
2684
2692
  const libav = await loadLibav(variant);
2685
2693
  const bridge = await loadBridge2();
2686
- const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
2694
+ const { prepareLibavInput } = await import('./libav-http-reader-2S5HAHW4.js');
2687
2695
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2688
2696
  const readPkt = await libav.av_packet_alloc();
2689
2697
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -3412,9 +3420,9 @@ var UnifiedPlayer = class _UnifiedPlayer {
3412
3420
  constructor(options, registry) {
3413
3421
  this.options = options;
3414
3422
  this.registry = registry;
3415
- const { requestInit, fetchFn } = options;
3416
- if (requestInit || fetchFn) {
3417
- this.transport = { requestInit, fetchFn };
3423
+ const { requestInit, fetchFn, cacheBytes } = options;
3424
+ if (requestInit || fetchFn || cacheBytes !== void 0) {
3425
+ this.transport = { requestInit, fetchFn, cacheBytes };
3418
3426
  }
3419
3427
  }
3420
3428
  options;
@@ -4565,7 +4573,7 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4565
4573
  * strategies pick up the new track via their textTracks watcher.
4566
4574
  */
4567
4575
  async addSubtitle(subtitle) {
4568
- const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-4T74JRGT.js');
4576
+ const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-5H24MEBJ.js');
4569
4577
  const format = subtitle.format ?? (subtitle.url.endsWith(".srt") ? "srt" : "vtt");
4570
4578
  const track = {
4571
4579
  id: this._subtitleTracks.length,
@@ -4574,14 +4582,27 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4574
4582
  sidecarUrl: subtitle.url
4575
4583
  };
4576
4584
  this._subtitleTracks.push(track);
4585
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
4577
4586
  await attachSubtitleTracks2(
4578
4587
  this._videoEl,
4579
4588
  this._subtitleTracks,
4580
4589
  void 0,
4581
4590
  (err, t) => {
4582
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
4591
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
4583
4592
  }
4584
4593
  );
4594
+ const textTracks = this._videoEl.textTracks;
4595
+ for (let i = 0; i < textTracks.length; i++) {
4596
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
4597
+ textTracks[i].mode = "showing";
4598
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
4599
+ break;
4600
+ }
4601
+ }
4602
+ this._dispatch("trackschange", {
4603
+ audioTracks: this._audioTracks,
4604
+ subtitleTracks: this.subtitleTracks
4605
+ });
4585
4606
  }
4586
4607
  /**
4587
4608
  * Disable the automatic `screen.orientation.lock()` that runs on
@@ -5012,6 +5033,12 @@ var PLAYER_STYLES = (
5012
5033
  display: flex;
5013
5034
  align-items: center;
5014
5035
  cursor: pointer;
5036
+ /* Claim all touch gestures on the seek bar. Without this, Android
5037
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
5038
+ * scroll candidates and cancel pointermove once the gesture
5039
+ * resolves, breaking scrub. touch-action must be set in CSS \u2014
5040
+ * preventDefault() on pointerdown is too late. */
5041
+ touch-action: none;
5015
5042
  }
5016
5043
 
5017
5044
  .avp-seek-track {
@@ -5029,7 +5056,13 @@ var PLAYER_STYLES = (
5029
5056
 
5030
5057
  .avp-seek-buffered {
5031
5058
  position: absolute;
5032
- left: 0;
5059
+ inset: 0;
5060
+ pointer-events: none;
5061
+ }
5062
+
5063
+ .avp-seek-buffered-range {
5064
+ position: absolute;
5065
+ top: 0;
5033
5066
  height: 100%;
5034
5067
  background: rgba(255, 255, 255, 0.35);
5035
5068
  border-radius: inherit;
@@ -5395,7 +5428,7 @@ function formatTime(sec) {
5395
5428
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
5396
5429
  }
5397
5430
  var PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
5398
- var CONTROLS_HIDE_MS = 3e3;
5431
+ var DEFAULT_CONTROLS_HIDE_MS = 3e3;
5399
5432
  var FORWARDED_EVENTS = [
5400
5433
  "ready",
5401
5434
  "error",
@@ -5438,7 +5471,7 @@ var PROXY_ATTRIBUTES = [
5438
5471
  ];
5439
5472
  var PLAYER_ATTRIBUTES = ["show-fit"];
5440
5473
  var FIT_MODES = ["contain", "cover", "fill"];
5441
- var AvbridgePlayerElement = class extends HTMLElement {
5474
+ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5442
5475
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5443
5476
  // ── Internal DOM refs ──────────────────────────────────────────────────
5444
5477
  _video;
@@ -5466,6 +5499,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5466
5499
  _state = "idle";
5467
5500
  _controlsTimer = null;
5468
5501
  _settingsOpen = false;
5502
+ _activeAudioTrackId = null;
5503
+ _activeSubtitleTrackId = null;
5469
5504
  _userSeeking = false;
5470
5505
  _holdTimer = null;
5471
5506
  _holdSpeedActive = false;
@@ -5587,6 +5622,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5587
5622
  on(this._video, "ended", () => this._setState("ended"));
5588
5623
  on(this._video, "error", () => this._setState("error"));
5589
5624
  on(this._video, "timeupdate", () => this._updateTime());
5625
+ on(this._video, "progress", () => this._updateBuffered());
5590
5626
  on(this._video, "volumechange", () => this._updateVolume());
5591
5627
  on(this._video, "trackschange", () => this._buildSettingsMenu());
5592
5628
  on(this._video, "durationchange", () => {
@@ -5729,6 +5765,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5729
5765
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5730
5766
  return frac * (this._video.duration || 0);
5731
5767
  }
5768
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5769
+ * preview-only). On narrow bars precise positioning is hard, so
5770
+ * immediate video feedback is more useful than a time tooltip. */
5771
+ static SCRUB_WIDTH_THRESHOLD = 400;
5732
5772
  _onSeekPointerDown(e) {
5733
5773
  if (e.button !== 0 && e.pointerType === "mouse") return;
5734
5774
  e.preventDefault();
@@ -5736,15 +5776,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5736
5776
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5737
5777
  seekBar.setPointerCapture(e.pointerId);
5738
5778
  seekBar.setAttribute("data-seeking", "");
5779
+ const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5780
+ let lastScrubCommit = 0;
5739
5781
  const initial = this._timeFromSeekPointer(e.clientX);
5740
5782
  this._seekInput.value = String(initial);
5741
5783
  this._onSeekInput();
5742
5784
  this._updateSeekTooltip(e.clientX);
5785
+ if (scrubMode) this._onSeekCommit();
5743
5786
  const onMove = (ev) => {
5744
5787
  const t = this._timeFromSeekPointer(ev.clientX);
5745
5788
  this._seekInput.value = String(t);
5746
5789
  this._onSeekInput();
5747
5790
  this._updateSeekTooltip(ev.clientX);
5791
+ if (scrubMode) {
5792
+ const now = performance.now();
5793
+ if (now - lastScrubCommit > 250) {
5794
+ lastScrubCommit = now;
5795
+ this._onSeekCommit();
5796
+ this._userSeeking = true;
5797
+ }
5798
+ }
5748
5799
  };
5749
5800
  const onUp = (ev) => {
5750
5801
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -5788,13 +5839,45 @@ var AvbridgePlayerElement = class extends HTMLElement {
5788
5839
  this._seekInput.value = String(t);
5789
5840
  this._updateSeekVisuals(t);
5790
5841
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
5842
+ this._updateBuffered();
5843
+ }
5844
+ /**
5845
+ * Render every buffered range as its own segment so gaps (common on MSE
5846
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
5847
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
5848
+ */
5849
+ _updateBuffered() {
5850
+ const d = this._video.duration;
5851
+ if (!(d > 0)) return;
5852
+ let buf;
5791
5853
  try {
5792
- const buf = this._video.buffered;
5793
- if (buf && buf.length > 0 && d > 0) {
5794
- const end = buf.end(buf.length - 1);
5795
- this._seekBuffered.style.width = `${end / d * 100}%`;
5796
- }
5854
+ buf = this._video.buffered;
5797
5855
  } catch {
5856
+ return;
5857
+ }
5858
+ const count = buf ? buf.length : 0;
5859
+ const host = this._seekBuffered;
5860
+ while (host.childElementCount > count) host.lastElementChild.remove();
5861
+ while (host.childElementCount < count) {
5862
+ const seg = document.createElement("div");
5863
+ seg.className = "avp-seek-buffered-range";
5864
+ host.appendChild(seg);
5865
+ }
5866
+ for (let i = 0; i < count; i++) {
5867
+ let start;
5868
+ let end;
5869
+ try {
5870
+ start = buf.start(i);
5871
+ end = buf.end(i);
5872
+ } catch {
5873
+ continue;
5874
+ }
5875
+ const s = Math.max(0, start);
5876
+ const e = Math.min(d, end);
5877
+ if (e <= s) continue;
5878
+ const seg = host.children[i];
5879
+ seg.style.left = `${s / d * 100}%`;
5880
+ seg.style.width = `${(e - s) / d * 100}%`;
5798
5881
  }
5799
5882
  }
5800
5883
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -5845,19 +5928,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
5845
5928
  sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5846
5929
  const audios = this._video.audioTracks ?? [];
5847
5930
  if (audios.length > 1) {
5931
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
5932
+ const activeAudio = audios.find((t) => t.id === activeAudioId) ?? audios[0];
5933
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
5848
5934
  let audioOpts = "";
5849
5935
  for (const t of audios) {
5850
- audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5936
+ const sel = t.id === activeAudioId ? " selected" : "";
5937
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5851
5938
  }
5852
- sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
5939
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
5853
5940
  }
5854
5941
  const subs = this._video.subtitleTracks ?? [];
5855
5942
  if (subs.length > 0) {
5856
- let subOpts = `<option value="-1" selected>Off</option>`;
5943
+ const activeSubId = this._activeSubtitleTrackId;
5944
+ const activeSub = activeSubId != null ? subs.find((t) => t.id === activeSubId) : null;
5945
+ const subValue = activeSub ? activeSub.language ?? `Track ${activeSub.id}` : "Off";
5946
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
5857
5947
  for (const t of subs) {
5858
- subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5948
+ const sel = t.id === activeSubId ? " selected" : "";
5949
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5859
5950
  }
5860
- sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
5951
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
5861
5952
  }
5862
5953
  if (this.hasAttribute("show-fit")) {
5863
5954
  const currentFit = this._video.fit ?? "contain";
@@ -5897,11 +5988,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
5897
5988
  this._video.playbackRate = Number(val);
5898
5989
  break;
5899
5990
  case "audio":
5991
+ this._activeAudioTrackId = Number(val);
5900
5992
  void this._video.setAudioTrack(Number(val));
5901
5993
  break;
5902
- case "subtitle":
5903
- void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
5994
+ case "subtitle": {
5995
+ const subId = Number(val);
5996
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
5997
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
5904
5998
  break;
5999
+ }
5905
6000
  case "fit":
5906
6001
  this.setAttribute("fit", val);
5907
6002
  break;
@@ -5996,16 +6091,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5996
6091
  _showControls() {
5997
6092
  this.showControls();
5998
6093
  }
5999
- _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
6094
+ _scheduleHide(durationMs) {
6095
+ const ms = durationMs ?? this._getControlsTimeout();
6000
6096
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
6001
6097
  if (this._state !== "playing" && this._state !== "buffering") return;
6002
6098
  if (this._settingsOpen) return;
6099
+ if (ms <= 0) return;
6003
6100
  this._controlsTimer = setTimeout(() => {
6004
6101
  if (this._state === "playing") {
6005
6102
  this.setAttribute("data-controls-hidden", "");
6006
6103
  this._toolbarTop.setAttribute("data-visible", "false");
6007
6104
  }
6008
- }, durationMs);
6105
+ }, ms);
6106
+ }
6107
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
6108
+ * Unset = default 3000ms. */
6109
+ _getControlsTimeout() {
6110
+ const attr = this.getAttribute("controls-timeout");
6111
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
6112
+ const n = Number(attr);
6113
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
6009
6114
  }
6010
6115
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
6011
6116
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -6017,6 +6122,9 @@ var AvbridgePlayerElement = class extends HTMLElement {
6017
6122
  // it's treated as a double-click and the single-click action is cancelled.
6018
6123
  /** Track whether the last interaction was touch so click handler can skip. */
6019
6124
  _lastPointerTypeWasTouch = false;
6125
+ /** True for ~50ms after a touch double-tap was handled, so the
6126
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
6127
+ _touchDoubleTapConsumed = false;
6020
6128
  /** True if the event's composed path passes through consumer-slotted
6021
6129
  * content (toolbar or content-overlay). Slotted content lives in the
6022
6130
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -6050,6 +6158,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
6050
6158
  _onContainerDblClick(e) {
6051
6159
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
6052
6160
  if (this._isSlottedContentEvent(e)) return;
6161
+ if (this._touchDoubleTapConsumed) return;
6053
6162
  if (this._tapTimer) {
6054
6163
  clearTimeout(this._tapTimer);
6055
6164
  this._tapTimer = null;
@@ -6091,6 +6200,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
6091
6200
  } else {
6092
6201
  this._toggleFullscreen();
6093
6202
  }
6203
+ this._touchDoubleTapConsumed = true;
6204
+ setTimeout(() => {
6205
+ this._touchDoubleTapConsumed = false;
6206
+ }, 100);
6094
6207
  this._lastTapTime = 0;
6095
6208
  return;
6096
6209
  }
@@ -6124,6 +6237,13 @@ var AvbridgePlayerElement = class extends HTMLElement {
6124
6237
  this._video.currentTime = Math.max(0, this._video.currentTime + delta);
6125
6238
  }
6126
6239
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
6240
+ /** Duration of one frame in seconds, derived from diagnostics fps or
6241
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
6242
+ _frameDuration() {
6243
+ const diag = this._video.getDiagnostics();
6244
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
6245
+ return 1 / fps;
6246
+ }
6127
6247
  _onKeydown(e) {
6128
6248
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
6129
6249
  switch (e.key) {
@@ -6168,6 +6288,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
6168
6288
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
6169
6289
  this._buildSettingsMenu();
6170
6290
  break;
6291
+ case ",":
6292
+ e.preventDefault();
6293
+ if (!this._video.paused) this._video.pause();
6294
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
6295
+ break;
6296
+ case ".":
6297
+ e.preventDefault();
6298
+ if (!this._video.paused) this._video.pause();
6299
+ this._video.currentTime = Math.min(
6300
+ this._video.duration || 0,
6301
+ this._video.currentTime + this._frameDuration()
6302
+ );
6303
+ break;
6171
6304
  case "Escape":
6172
6305
  if (this._settingsOpen) {
6173
6306
  e.preventDefault();