avbridge 2.8.4 → 2.10.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 (79) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +74 -1
  3. package/dist/{avi-F6WZJK5T.cjs → avi-2ILLBNPQ.cjs} +8 -2
  4. package/dist/avi-2ILLBNPQ.cjs.map +1 -0
  5. package/dist/{avi-W6L3BTWU.cjs → avi-B5CQYB7L.cjs} +8 -2
  6. package/dist/avi-B5CQYB7L.cjs.map +1 -0
  7. package/dist/{avi-2JPBSHGA.js → avi-JXU4GQL2.js} +8 -2
  8. package/dist/avi-JXU4GQL2.js.map +1 -0
  9. package/dist/{avi-NJXAXUXK.js → avi-RWWPN2PR.js} +8 -2
  10. package/dist/avi-RWWPN2PR.js.map +1 -0
  11. package/dist/{chunk-X2K3GIWE.js → chunk-2NSOOMXW.js} +14 -3
  12. package/dist/chunk-2NSOOMXW.js.map +1 -0
  13. package/dist/{chunk-KBWQRGHS.js → chunk-3GKM5DFM.js} +119 -8
  14. package/dist/chunk-3GKM5DFM.js.map +1 -0
  15. package/dist/{chunk-ZCUXHW55.cjs → chunk-BYGZN4Z5.cjs} +5 -5
  16. package/dist/{chunk-ZCUXHW55.cjs.map → chunk-BYGZN4Z5.cjs.map} +1 -1
  17. package/dist/{chunk-SMH6IOP2.js → chunk-CL6UEUQF.js} +4 -4
  18. package/dist/{chunk-SMH6IOP2.js.map → chunk-CL6UEUQF.js.map} +1 -1
  19. package/dist/{chunk-SR3MPV4D.js → chunk-GYIJU44C.js} +5 -5
  20. package/dist/{chunk-SR3MPV4D.js.map → chunk-GYIJU44C.js.map} +1 -1
  21. package/dist/{chunk-CPZ7PXAM.cjs → chunk-L7A3ECI2.cjs} +14 -2
  22. package/dist/chunk-L7A3ECI2.cjs.map +1 -0
  23. package/dist/{chunk-YX4AGLNF.cjs → chunk-NQULEIA3.cjs} +129 -18
  24. package/dist/chunk-NQULEIA3.cjs.map +1 -0
  25. package/dist/{chunk-Q2VUO52Z.cjs → chunk-OTFS7DC4.cjs} +12 -12
  26. package/dist/{chunk-Q2VUO52Z.cjs.map → chunk-OTFS7DC4.cjs.map} +1 -1
  27. package/dist/element-browser.js +144 -10
  28. package/dist/element-browser.js.map +1 -1
  29. package/dist/element.cjs +16 -10
  30. package/dist/element.cjs.map +1 -1
  31. package/dist/element.d.cts +11 -6
  32. package/dist/element.d.ts +11 -6
  33. package/dist/element.js +15 -9
  34. package/dist/element.js.map +1 -1
  35. package/dist/index.cjs +20 -20
  36. package/dist/index.d.cts +2 -2
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.js +8 -8
  39. package/dist/libav-demux-3N5Y3VQA.cjs +31 -0
  40. package/dist/{libav-demux-H2GS46GH.cjs.map → libav-demux-3N5Y3VQA.cjs.map} +1 -1
  41. package/dist/libav-demux-JXD4OTLM.js +6 -0
  42. package/dist/{libav-demux-OWZ4T2YW.js.map → libav-demux-JXD4OTLM.js.map} +1 -1
  43. package/dist/{player-BptSJPfn.d.cts → player-DDdNVFDv.d.cts} +24 -2
  44. package/dist/{player-BptSJPfn.d.ts → player-DDdNVFDv.d.ts} +24 -2
  45. package/dist/player.cjs +413 -117
  46. package/dist/player.cjs.map +1 -1
  47. package/dist/player.d.cts +44 -11
  48. package/dist/player.d.ts +44 -11
  49. package/dist/player.js +413 -117
  50. package/dist/player.js.map +1 -1
  51. package/dist/{remux-WBYIZBBX.js → remux-56V7LDAD.js} +5 -5
  52. package/dist/{remux-WBYIZBBX.js.map → remux-56V7LDAD.js.map} +1 -1
  53. package/dist/{remux-OBSMIENG.cjs → remux-KUS5GIL6.cjs} +10 -10
  54. package/dist/{remux-OBSMIENG.cjs.map → remux-KUS5GIL6.cjs.map} +1 -1
  55. package/package.json +1 -1
  56. package/src/classify/rules.ts +2 -0
  57. package/src/element/avbridge-player.ts +172 -86
  58. package/src/element/avbridge-video.ts +22 -6
  59. package/src/element/player-styles.ts +149 -34
  60. package/src/index.ts +1 -0
  61. package/src/probe/avi.ts +2 -0
  62. package/src/strategies/fallback/audio-output.ts +29 -4
  63. package/src/strategies/fallback/decoder.ts +30 -0
  64. package/src/strategies/fallback/index.ts +42 -0
  65. package/src/strategies/hybrid/decoder.ts +35 -0
  66. package/src/strategies/hybrid/index.ts +26 -0
  67. package/src/strategies/remux/index.ts +8 -0
  68. package/src/types.ts +31 -0
  69. package/src/util/libav-demux.ts +26 -0
  70. package/dist/avi-2JPBSHGA.js.map +0 -1
  71. package/dist/avi-F6WZJK5T.cjs.map +0 -1
  72. package/dist/avi-NJXAXUXK.js.map +0 -1
  73. package/dist/avi-W6L3BTWU.cjs.map +0 -1
  74. package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
  75. package/dist/chunk-KBWQRGHS.js.map +0 -1
  76. package/dist/chunk-X2K3GIWE.js.map +0 -1
  77. package/dist/chunk-YX4AGLNF.cjs.map +0 -1
  78. package/dist/libav-demux-H2GS46GH.cjs +0 -27
  79. package/dist/libav-demux-OWZ4T2YW.js +0 -6
package/dist/player.js CHANGED
@@ -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-2JPBSHGA.js');
240
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.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-2JPBSHGA.js');
253
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.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-2JPBSHGA.js');
267
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
268
268
  return await probeWithLibav(normalized, sniffed);
269
269
  } catch (err) {
270
270
  const inner = err instanceof Error ? err.message : String(err);
@@ -369,7 +369,13 @@ var FALLBACK_VIDEO_CODECS = /* @__PURE__ */ new Set([
369
369
  "rv40",
370
370
  "mpeg2",
371
371
  "mpeg1",
372
- "theora"
372
+ "theora",
373
+ "dv",
374
+ "hq_hqa",
375
+ "rawvideo",
376
+ "qtrle",
377
+ "png",
378
+ "vp6f"
373
379
  ]);
374
380
  var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
375
381
  "wmav2",
@@ -1205,6 +1211,12 @@ async function createRemuxSession(context, video) {
1205
1211
  }
1206
1212
  const wasPlaying = !video.paused;
1207
1213
  await pipeline.seek(time, wasPlaying || wantPlay);
1214
+ queueMicrotask(() => {
1215
+ try {
1216
+ video.dispatchEvent(new Event("seeked"));
1217
+ } catch {
1218
+ }
1219
+ });
1208
1220
  },
1209
1221
  async setAudioTrack(id) {
1210
1222
  if (!context.audioTracks.some((t) => t.id === id)) {
@@ -1593,6 +1605,10 @@ var AudioOutput = class {
1593
1605
  _volume = 1;
1594
1606
  /** User-set muted flag. When true, gain is forced to 0. */
1595
1607
  _muted = false;
1608
+ /** Playback rate. Scales the media clock and each AudioBufferSourceNode's
1609
+ * playbackRate so audio pitches up/down accordingly (same as native
1610
+ * <video>.playbackRate). Default 1. */
1611
+ _rate = 1;
1596
1612
  constructor() {
1597
1613
  this.ctx = new AudioContext();
1598
1614
  this.gain = this.ctx.createGain();
@@ -1614,6 +1630,20 @@ var AudioOutput = class {
1614
1630
  getMuted() {
1615
1631
  return this._muted;
1616
1632
  }
1633
+ /** Set playback rate. Scales the media clock and pitches audio output
1634
+ * (same as native <video>.playbackRate — speed without pitch correction).
1635
+ * Rebases the anchor so the clock transition is seamless. */
1636
+ setPlaybackRate(rate) {
1637
+ if (rate === this._rate) return;
1638
+ const t = this.now();
1639
+ this.mediaTimeOfAnchor = t;
1640
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1641
+ this.wallAnchorMs = performance.now();
1642
+ this._rate = rate;
1643
+ }
1644
+ getPlaybackRate() {
1645
+ return this._rate;
1646
+ }
1617
1647
  applyGain() {
1618
1648
  const target = this._muted ? 0 : this._volume;
1619
1649
  try {
@@ -1634,12 +1664,12 @@ var AudioOutput = class {
1634
1664
  now() {
1635
1665
  if (this.noAudio) {
1636
1666
  if (this.state === "playing") {
1637
- return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3;
1667
+ return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3 * this._rate;
1638
1668
  }
1639
1669
  return this.mediaTimeOfAnchor;
1640
1670
  }
1641
1671
  if (this.state === "playing") {
1642
- return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
1672
+ return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1643
1673
  }
1644
1674
  return this.mediaTimeOfAnchor;
1645
1675
  }
@@ -1691,7 +1721,8 @@ var AudioOutput = class {
1691
1721
  const node = this.ctx.createBufferSource();
1692
1722
  node.buffer = buffer;
1693
1723
  node.connect(this.gain);
1694
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
1724
+ if (this._rate !== 1) node.playbackRate.value = this._rate;
1725
+ let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1695
1726
  if (ctxStart < this.ctx.currentTime) {
1696
1727
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1697
1728
  this.mediaTimeOfAnchor = this.mediaTimeOfNext;
@@ -1841,6 +1872,17 @@ function sanitizePacketTimestamp(pkt, nextUs, fallbackTimeBase) {
1841
1872
  pkt.time_base_num = 1;
1842
1873
  pkt.time_base_den = 1e6;
1843
1874
  }
1875
+ function packetPtsSec(pkt, timeBase) {
1876
+ const lo = pkt.pts ?? 0;
1877
+ const hi = pkt.ptshi ?? 0;
1878
+ const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
1879
+ if (isInvalid) return null;
1880
+ const tb = timeBase ?? [1, 1e6];
1881
+ if (!tb[0] || !tb[1]) return null;
1882
+ const pts64 = hi * 4294967296 + lo;
1883
+ const sec = pts64 * tb[0] / tb[1];
1884
+ return Number.isFinite(sec) ? sec : null;
1885
+ }
1844
1886
  var AV_SAMPLE_FMT_U8 = 0;
1845
1887
  var AV_SAMPLE_FMT_S16 = 1;
1846
1888
  var AV_SAMPLE_FMT_S32 = 2;
@@ -2101,6 +2143,7 @@ async function startHybridDecoder(opts) {
2101
2143
  let videoFramesDecoded = 0;
2102
2144
  let audioFramesDecoded = 0;
2103
2145
  let videoChunksFed = 0;
2146
+ let bufferedUntilSec = 0;
2104
2147
  let syntheticVideoUs = 0;
2105
2148
  let syntheticAudioUs = 0;
2106
2149
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -2121,6 +2164,18 @@ async function startHybridDecoder(opts) {
2121
2164
  if (myToken !== pumpToken || destroyed) return;
2122
2165
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2123
2166
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2167
+ if (videoPackets && videoTimeBase) {
2168
+ for (const pkt of videoPackets) {
2169
+ const sec = packetPtsSec(pkt, videoTimeBase);
2170
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2171
+ }
2172
+ }
2173
+ if (audioPackets && audioTimeBase) {
2174
+ for (const pkt of audioPackets) {
2175
+ const sec = packetPtsSec(pkt, audioTimeBase);
2176
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2177
+ }
2178
+ }
2124
2179
  if (audioDec && audioPackets && audioPackets.length > 0) {
2125
2180
  await decodeAudioBatch(audioPackets, myToken);
2126
2181
  }
@@ -2377,6 +2432,9 @@ async function startHybridDecoder(opts) {
2377
2432
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2378
2433
  );
2379
2434
  },
2435
+ bufferedUntilSec() {
2436
+ return bufferedUntilSec;
2437
+ },
2380
2438
  stats() {
2381
2439
  return {
2382
2440
  decoderType: "webcodecs-hybrid",
@@ -2492,6 +2550,14 @@ async function createHybridSession(ctx, target, transport) {
2492
2550
  get: () => ctx.duration ?? NaN
2493
2551
  });
2494
2552
  }
2553
+ Object.defineProperty(target, "playbackRate", {
2554
+ configurable: true,
2555
+ get: () => audio.getPlaybackRate(),
2556
+ set: (v) => {
2557
+ audio.setPlaybackRate(v);
2558
+ target.dispatchEvent(new Event("ratechange"));
2559
+ }
2560
+ });
2495
2561
  Object.defineProperty(target, "readyState", {
2496
2562
  configurable: true,
2497
2563
  get: () => {
@@ -2504,6 +2570,13 @@ async function createHybridSession(ctx, target, transport) {
2504
2570
  configurable: true,
2505
2571
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
2506
2572
  });
2573
+ Object.defineProperty(target, "buffered", {
2574
+ configurable: true,
2575
+ get: () => {
2576
+ const end = handles.bufferedUntilSec();
2577
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
2578
+ }
2579
+ });
2507
2580
  async function waitForBuffer() {
2508
2581
  const start = performance.now();
2509
2582
  while (true) {
@@ -2517,6 +2590,7 @@ async function createHybridSession(ctx, target, transport) {
2517
2590
  }
2518
2591
  async function doSeek(timeSec) {
2519
2592
  const wasPlaying = audio.isPlaying();
2593
+ target.dispatchEvent(new Event("seeking"));
2520
2594
  await audio.pause().catch(() => {
2521
2595
  });
2522
2596
  await handles.seek(timeSec).catch(
@@ -2528,7 +2602,14 @@ async function createHybridSession(ctx, target, transport) {
2528
2602
  await waitForBuffer();
2529
2603
  await audio.start();
2530
2604
  }
2605
+ target.dispatchEvent(new Event("seeked"));
2531
2606
  }
2607
+ queueMicrotask(() => {
2608
+ try {
2609
+ target.dispatchEvent(new Event("loadedmetadata"));
2610
+ } catch {
2611
+ }
2612
+ });
2532
2613
  let fatalErrorHandler = null;
2533
2614
  handles.onFatalError((reason) => fatalErrorHandler?.(reason));
2534
2615
  return {
@@ -2587,6 +2668,7 @@ async function createHybridSession(ctx, target, transport) {
2587
2668
  delete target.muted;
2588
2669
  delete target.readyState;
2589
2670
  delete target.seekable;
2671
+ delete target.playbackRate;
2590
2672
  } catch {
2591
2673
  }
2592
2674
  },
@@ -2713,6 +2795,7 @@ async function startDecoder(opts) {
2713
2795
  let pumpRunning = null;
2714
2796
  let packetsRead = 0;
2715
2797
  let videoFramesDecoded = 0;
2798
+ let bufferedUntilSec = 0;
2716
2799
  let audioFramesDecoded = 0;
2717
2800
  let watchdogFirstFrameMs = 0;
2718
2801
  let watchdogSlowSinceMs = 0;
@@ -2738,6 +2821,18 @@ async function startDecoder(opts) {
2738
2821
  if (myToken !== pumpToken || destroyed) return;
2739
2822
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2740
2823
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2824
+ if (videoPackets && videoTimeBase) {
2825
+ for (const pkt of videoPackets) {
2826
+ const sec = packetPtsSec(pkt, videoTimeBase);
2827
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2828
+ }
2829
+ }
2830
+ if (audioPackets && audioTimeBase) {
2831
+ for (const pkt of audioPackets) {
2832
+ const sec = packetPtsSec(pkt, audioTimeBase);
2833
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2834
+ }
2835
+ }
2741
2836
  if (audioDec && audioPackets && audioPackets.length > 0) {
2742
2837
  await decodeAudioBatch(audioPackets, myToken);
2743
2838
  }
@@ -3019,6 +3114,9 @@ async function startDecoder(opts) {
3019
3114
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3020
3115
  );
3021
3116
  },
3117
+ bufferedUntilSec() {
3118
+ return bufferedUntilSec;
3119
+ },
3022
3120
  stats() {
3023
3121
  return {
3024
3122
  decoderType: "libav-wasm",
@@ -3106,6 +3204,14 @@ async function createFallbackSession(ctx, target, transport) {
3106
3204
  get: () => ctx.duration ?? NaN
3107
3205
  });
3108
3206
  }
3207
+ Object.defineProperty(target, "playbackRate", {
3208
+ configurable: true,
3209
+ get: () => audio.getPlaybackRate(),
3210
+ set: (v) => {
3211
+ audio.setPlaybackRate(v);
3212
+ target.dispatchEvent(new Event("ratechange"));
3213
+ }
3214
+ });
3109
3215
  Object.defineProperty(target, "readyState", {
3110
3216
  configurable: true,
3111
3217
  get: () => {
@@ -3118,6 +3224,13 @@ async function createFallbackSession(ctx, target, transport) {
3118
3224
  configurable: true,
3119
3225
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
3120
3226
  });
3227
+ Object.defineProperty(target, "buffered", {
3228
+ configurable: true,
3229
+ get: () => {
3230
+ const end = handles.bufferedUntilSec();
3231
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
3232
+ }
3233
+ });
3121
3234
  async function waitForBuffer() {
3122
3235
  const start = performance.now();
3123
3236
  let firstFrameAtMs = 0;
@@ -3157,6 +3270,7 @@ async function createFallbackSession(ctx, target, transport) {
3157
3270
  }
3158
3271
  async function doSeek(timeSec) {
3159
3272
  const wasPlaying = audio.isPlaying();
3273
+ target.dispatchEvent(new Event("seeking"));
3160
3274
  await audio.pause().catch(() => {
3161
3275
  });
3162
3276
  await handles.seek(timeSec).catch(
@@ -3168,7 +3282,14 @@ async function createFallbackSession(ctx, target, transport) {
3168
3282
  await waitForBuffer();
3169
3283
  await audio.start();
3170
3284
  }
3285
+ target.dispatchEvent(new Event("seeked"));
3171
3286
  }
3287
+ queueMicrotask(() => {
3288
+ try {
3289
+ target.dispatchEvent(new Event("loadedmetadata"));
3290
+ } catch {
3291
+ }
3292
+ });
3172
3293
  return {
3173
3294
  strategy: "fallback",
3174
3295
  async play() {
@@ -3222,6 +3343,7 @@ async function createFallbackSession(ctx, target, transport) {
3222
3343
  delete target.muted;
3223
3344
  delete target.readyState;
3224
3345
  delete target.seekable;
3346
+ delete target.playbackRate;
3225
3347
  } catch {
3226
3348
  }
3227
3349
  },
@@ -4248,9 +4370,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4248
4370
  else this.removeAttribute("autoplay");
4249
4371
  }
4250
4372
  get muted() {
4251
- return this.hasAttribute("muted");
4373
+ return this._videoEl.muted;
4252
4374
  }
4253
4375
  set muted(value) {
4376
+ this._videoEl.muted = value;
4254
4377
  if (value) this.setAttribute("muted", "");
4255
4378
  else this.removeAttribute("muted");
4256
4379
  }
@@ -4315,11 +4438,16 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4315
4438
  }
4316
4439
  /**
4317
4440
  * Buffered time ranges for the active source. Mirrors the standard
4318
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
4319
- * this reflects the underlying SourceBuffer / progressive download state.
4320
- * For the hybrid and fallback (canvas-rendered) strategies it currently
4321
- * returns an empty TimeRanges; a future release will synthesize a coarse
4322
- * range from the decoder's read position.
4441
+ * `<video>.buffered` `TimeRanges` API.
4442
+ *
4443
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
4444
+ * (reflects the browser's SourceBuffer / progressive-download state).
4445
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
4446
+ * from the demuxer's read progress — "how far libav has ever pumped
4447
+ * packets through." Monotonic; does not shrink on seek. This is an
4448
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
4449
+ * are consumed in flight, so we can't report per-range availability
4450
+ * the way MSE does. Enough for a seek-bar buffered indicator.
4323
4451
  */
4324
4452
  get buffered() {
4325
4453
  return this._videoEl.buffered;
@@ -4623,11 +4751,17 @@ var PLAYER_STYLES = (
4623
4751
 
4624
4752
  /* \u2500\u2500 Container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4625
4753
 
4754
+ :host {
4755
+ -webkit-tap-highlight-color: transparent;
4756
+ outline: none;
4757
+ }
4758
+
4626
4759
  .avp {
4627
4760
  position: relative;
4628
4761
  width: 100%;
4629
4762
  height: 100%;
4630
- cursor: pointer;
4763
+ -webkit-tap-highlight-color: transparent;
4764
+ user-select: none;
4631
4765
  }
4632
4766
 
4633
4767
  .avp avbridge-video {
@@ -4826,7 +4960,14 @@ var PLAYER_STYLES = (
4826
4960
  pointer-events: auto;
4827
4961
  }
4828
4962
 
4829
- .avp-toolbar-top-right { margin-left: auto; }
4963
+ /* Left slot fills remaining space so slotted text/content can grow.
4964
+ min-width: 0 prevents flex children from overflowing the toolbar. */
4965
+ .avp-toolbar-top-left {
4966
+ flex: 1;
4967
+ min-width: 0;
4968
+ }
4969
+
4970
+ .avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
4830
4971
 
4831
4972
  /* Hide the gradient band when no consumer has slotted anything \u2014 we
4832
4973
  toggle data-toolbar-empty from JS via slotchange. */
@@ -4839,6 +4980,30 @@ var PLAYER_STYLES = (
4839
4980
  pointer-events: none;
4840
4981
  }
4841
4982
 
4983
+ /* \u2500\u2500 Content overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4984
+ /* Consumer-provided rich content (tweet cards, media info, annotations).
4985
+ Sits above the video, below the play-button overlay and controls in
4986
+ z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
4987
+ so taps fall through to the video; consumers opt in on their content
4988
+ with pointer-events:auto. */
4989
+
4990
+ .avp-content-overlay {
4991
+ position: absolute;
4992
+ inset: 0;
4993
+ z-index: 1;
4994
+ pointer-events: none;
4995
+ opacity: 1;
4996
+ transition: opacity 0.25s;
4997
+ }
4998
+
4999
+ .avp-content-overlay ::slotted(*) {
5000
+ pointer-events: auto;
5001
+ }
5002
+
5003
+ :host([data-controls-hidden]) .avp-content-overlay {
5004
+ opacity: 0;
5005
+ }
5006
+
4842
5007
  /* \u2500\u2500 Seek bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4843
5008
 
4844
5009
  .avp-seek {
@@ -4925,6 +5090,15 @@ var PLAYER_STYLES = (
4925
5090
 
4926
5091
  .avp-seek:hover .avp-seek-tooltip { display: block; }
4927
5092
 
5093
+ /* Show tooltip during active drag (touch or mouse). The JS side sets
5094
+ data-seeking on .avp-seek while the user is scrubbing. */
5095
+ .avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
5096
+
5097
+ /* Enlarge thumb while scrubbing. */
5098
+ .avp-seek[data-seeking] .avp-seek-thumb {
5099
+ transform: translate(-50%, -50%) scale(1.4);
5100
+ }
5101
+
4928
5102
  /* \u2500\u2500 Bottom row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4929
5103
 
4930
5104
  .avp-bottom {
@@ -5035,60 +5209,114 @@ var PLAYER_STYLES = (
5035
5209
 
5036
5210
  .avp-spacer { flex: 1; }
5037
5211
 
5038
- /* \u2500\u2500 Settings menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5212
+ /* \u2500\u2500 Settings bottom sheet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5039
5213
 
5214
+ /* Scrim \u2014 semi-transparent overlay behind the sheet, above the video.
5215
+ Tapping it dismisses the sheet. */
5216
+ .avp-settings-scrim {
5217
+ position: absolute;
5218
+ inset: 0;
5219
+ z-index: 9;
5220
+ background: rgba(0, 0, 0, 0.4);
5221
+ opacity: 0;
5222
+ pointer-events: none;
5223
+ transition: opacity 0.2s;
5224
+ }
5225
+
5226
+ .avp-settings-scrim.open {
5227
+ opacity: 1;
5228
+ pointer-events: auto;
5229
+ }
5230
+
5231
+ /* Sheet container \u2014 slides up from the bottom. Height is content-driven
5232
+ up to a JS-measured max (set on open via style.maxHeight). */
5040
5233
  .avp-settings {
5041
5234
  position: absolute;
5042
- bottom: 52px;
5043
- right: 12px;
5044
- background: rgba(28, 28, 28, 0.95);
5045
- border-radius: 8px;
5046
- min-width: 220px;
5047
- max-height: 300px;
5048
- overflow-y: auto;
5049
- display: none;
5235
+ bottom: 0;
5236
+ left: 0;
5237
+ right: 0;
5050
5238
  z-index: 10;
5051
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
5239
+ background: rgba(28, 28, 28, 0.97);
5240
+ border-radius: 12px 12px 0 0;
5241
+ overflow-y: auto;
5242
+ overscroll-behavior: contain;
5243
+ transform: translateY(100%);
5244
+ transition: transform 0.2s ease-out;
5245
+ max-height: 70%;
5246
+ padding-bottom: 52px;
5247
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
5052
5248
  }
5053
5249
 
5054
- .avp-settings.open { display: block; }
5250
+ .avp-settings.open {
5251
+ transform: translateY(0);
5252
+ }
5055
5253
 
5056
- .avp-settings-section {
5057
- padding: 8px 0;
5058
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
5254
+ /* Drag handle indicator at top of sheet. */
5255
+ .avp-settings-handle {
5256
+ width: 36px;
5257
+ height: 4px;
5258
+ border-radius: 2px;
5259
+ background: rgba(255, 255, 255, 0.3);
5260
+ margin: 8px auto 4px;
5059
5261
  }
5060
5262
 
5061
- .avp-settings-section:last-child { border-bottom: none; }
5263
+ /* \u2500\u2500 Accordion sections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5062
5264
 
5063
- .avp-settings-label {
5064
- padding: 4px 16px;
5065
- font-size: 11px;
5066
- text-transform: uppercase;
5067
- letter-spacing: 0.5px;
5068
- opacity: 0.5;
5265
+ .avp-settings-section {
5266
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
5069
5267
  }
5070
5268
 
5071
- .avp-settings-item {
5269
+ .avp-settings-section:last-child { border-bottom: none; }
5270
+
5271
+ /* Section header \u2014 clickable row showing label + current value. */
5272
+ .avp-settings-header {
5273
+ position: relative;
5072
5274
  display: flex;
5073
5275
  align-items: center;
5074
- padding: 8px 16px;
5075
- font-size: 13px;
5276
+ justify-content: space-between;
5277
+ padding: 12px 16px;
5076
5278
  cursor: pointer;
5279
+ font-size: 14px;
5077
5280
  transition: background 0.1s;
5078
5281
  }
5079
5282
 
5080
- .avp-settings-item:hover { background: rgba(255, 255, 255, 0.1); }
5283
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
5081
5284
 
5082
- .avp-settings-item.active {
5083
- color: #3ea6ff;
5285
+ .avp-settings-header-label {
5286
+ display: flex;
5287
+ align-items: center;
5288
+ gap: 8px;
5289
+ font-weight: 500;
5290
+ }
5291
+
5292
+ .avp-settings-header-value {
5293
+ margin-left: auto;
5294
+ opacity: 0.6;
5295
+ font-size: 13px;
5296
+ text-align: right;
5084
5297
  }
5085
5298
 
5086
- .avp-settings-item.active::before {
5087
- content: "\\2713";
5088
- margin-right: 8px;
5089
- font-weight: bold;
5299
+ /* Invisible native <select> layered over the value portion of the row.
5300
+ Covers from the value text to the right edge so tapping the value
5301
+ opens the OS picker. The label side remains inert. */
5302
+ .avp-settings-select {
5303
+ position: absolute;
5304
+ top: 0;
5305
+ right: 0;
5306
+ bottom: 0;
5307
+ width: 50%;
5308
+ opacity: 0;
5309
+ cursor: pointer;
5310
+ font-size: 16px;
5311
+ direction: rtl;
5090
5312
  }
5091
5313
 
5314
+ /* Toggle-style rows (Stats for Nerds) \u2014 no select, just clickable. */
5315
+ .avp-settings-toggle {
5316
+ cursor: pointer;
5317
+ }
5318
+ .avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
5319
+
5092
5320
  /* \u2500\u2500 Stats for nerds \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5093
5321
 
5094
5322
  .avp-stats {
@@ -5116,9 +5344,24 @@ var PLAYER_STYLES = (
5116
5344
  @media (pointer: coarse) {
5117
5345
  .avp-btn svg { width: 28px; height: 28px; }
5118
5346
  .avp-btn { padding: 8px; }
5347
+
5348
+ /* Taller touch target on mobile (44px, matching YouTube Mobile)
5349
+ while keeping the visual track thin. Negative margin collapses
5350
+ the extra space so the controls layout doesn't shift. */
5351
+ .avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
5119
5352
  .avp-seek-track { height: 4px; }
5120
5353
  .avp-seek:hover .avp-seek-track { height: 4px; }
5121
- .avp-seek-thumb { transform: translate(-50%, -50%) scale(1); }
5354
+ .avp-seek-thumb {
5355
+ transform: translate(-50%, -50%) scale(1);
5356
+ width: 16px;
5357
+ height: 16px;
5358
+ }
5359
+ .avp-seek[data-seeking] .avp-seek-thumb {
5360
+ transform: translate(-50%, -50%) scale(1.5);
5361
+ }
5362
+ /* Move tooltip above the taller touch zone. */
5363
+ .avp-seek-tooltip { bottom: 32px; }
5364
+
5122
5365
  .avp-volume:hover .avp-volume-slider { width: 0; }
5123
5366
  .avp-overlay-btn { width: 56px; height: 56px; }
5124
5367
  .avp-overlay-btn svg { width: 30px; height: 30px; }
@@ -5211,6 +5454,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5211
5454
  _volumeInput;
5212
5455
  _settingsBtn;
5213
5456
  _settingsMenu;
5457
+ _settingsScrim;
5458
+ _customSections = [];
5214
5459
  _fullscreenBtn;
5215
5460
  // Strategy badge removed — visible in Stats for Nerds instead.
5216
5461
  // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
@@ -5252,6 +5497,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5252
5497
  this._volumeInput = shadow.querySelector(".avp-volume-input");
5253
5498
  this._settingsBtn = shadow.querySelector(".avp-settings-btn");
5254
5499
  this._settingsMenu = shadow.querySelector(".avp-settings");
5500
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim");
5255
5501
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen");
5256
5502
  this._speedIndicator = shadow.querySelector(".avp-speed-indicator");
5257
5503
  this._statsEl = shadow.querySelector(".avp-stats");
@@ -5276,6 +5522,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5276
5522
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5277
5523
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5278
5524
  </div>
5525
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
5279
5526
  <div part="overlay" class="avp-overlay">
5280
5527
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5281
5528
  <div class="avp-spinner"></div>
@@ -5307,7 +5554,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5307
5554
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
5308
5555
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
5309
5556
  </div>
5310
- <div class="avp-settings" part="settings-menu"></div>
5557
+ <div class="avp-settings-scrim"></div>
5558
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
5311
5559
  </div>
5312
5560
  </div>`;
5313
5561
  }
@@ -5379,6 +5627,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5379
5627
  e.stopPropagation();
5380
5628
  this._toggleSettings();
5381
5629
  });
5630
+ on(this._settingsScrim, "click", () => this._closeSettings());
5382
5631
  on(this._fullscreenBtn, "click", (e) => {
5383
5632
  e.stopPropagation();
5384
5633
  this._toggleFullscreen();
@@ -5387,11 +5636,6 @@ var AvbridgePlayerElement = class extends HTMLElement {
5387
5636
  const container = this.shadowRoot.querySelector(".avp");
5388
5637
  on(container, "click", (e) => this._onContainerClick(e));
5389
5638
  on(container, "dblclick", (e) => this._onContainerDblClick(e));
5390
- on(container, "click", (e) => {
5391
- if (this._settingsOpen && !e.target.closest?.(".avp-settings-btn, .avp-settings")) {
5392
- this._closeSettings();
5393
- }
5394
- });
5395
5639
  on(document, "click", (e) => {
5396
5640
  if (this._settingsOpen && !this.contains(e.target)) {
5397
5641
  this._closeSettings();
@@ -5491,19 +5735,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5491
5735
  this._userSeeking = true;
5492
5736
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5493
5737
  seekBar.setPointerCapture(e.pointerId);
5738
+ seekBar.setAttribute("data-seeking", "");
5494
5739
  const initial = this._timeFromSeekPointer(e.clientX);
5495
5740
  this._seekInput.value = String(initial);
5496
5741
  this._onSeekInput();
5742
+ this._updateSeekTooltip(e.clientX);
5497
5743
  const onMove = (ev) => {
5498
5744
  const t = this._timeFromSeekPointer(ev.clientX);
5499
5745
  this._seekInput.value = String(t);
5500
5746
  this._onSeekInput();
5747
+ this._updateSeekTooltip(ev.clientX);
5501
5748
  };
5502
5749
  const onUp = (ev) => {
5503
5750
  const t = this._timeFromSeekPointer(ev.clientX);
5504
5751
  this._seekInput.value = String(t);
5505
5752
  this._onSeekCommit();
5506
5753
  this._seekInput.focus();
5754
+ seekBar.removeAttribute("data-seeking");
5507
5755
  seekBar.removeEventListener("pointermove", onMove);
5508
5756
  seekBar.removeEventListener("pointerup", onUp);
5509
5757
  seekBar.removeEventListener("pointercancel", onUp);
@@ -5517,8 +5765,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
5517
5765
  seekBar.addEventListener("pointercancel", onUp);
5518
5766
  }
5519
5767
  _onSeekHover(e) {
5768
+ this._updateSeekTooltip(e.clientX);
5769
+ }
5770
+ _updateSeekTooltip(clientX) {
5520
5771
  const rect = this._seekInput.getBoundingClientRect();
5521
- const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
5772
+ const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5522
5773
  const t = frac * (this._video.duration || 0);
5523
5774
  this._seekTooltip.textContent = formatTime(t);
5524
5775
  this._seekTooltip.style.left = `${frac * 100}%`;
@@ -5562,83 +5813,111 @@ var AvbridgePlayerElement = class extends HTMLElement {
5562
5813
  _toggleSettings() {
5563
5814
  this._settingsOpen = !this._settingsOpen;
5564
5815
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
5565
- if (this._settingsOpen) this._showControls();
5816
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
5817
+ if (this._settingsOpen) {
5818
+ this._fitSettingsToPlayer();
5819
+ this._showControls();
5820
+ }
5821
+ }
5822
+ _fitSettingsToPlayer() {
5823
+ const container = this.shadowRoot?.querySelector(".avp");
5824
+ if (!container) return;
5825
+ const rect = container.getBoundingClientRect();
5826
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
5827
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
5566
5828
  }
5567
5829
  _closeSettings() {
5568
5830
  this._settingsOpen = false;
5569
5831
  this._settingsMenu.classList.remove("open");
5832
+ this._settingsScrim.classList.remove("open");
5570
5833
  }
5571
5834
  _buildSettingsMenu() {
5572
5835
  const sections = [];
5573
- if (this.hasAttribute("show-fit")) {
5574
- const currentFit = this._video.fit ?? "contain";
5575
- let fitItems = "";
5576
- for (const mode of FIT_MODES) {
5577
- const active = mode === currentFit;
5578
- const label = mode[0].toUpperCase() + mode.slice(1);
5579
- fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
5580
- }
5581
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
5582
- }
5836
+ const selectRow = (label, currentValue, options, selectAttrs) => `<div class="avp-settings-section"><div class="avp-settings-header"><span class="avp-settings-header-label">${label}</span><span class="avp-settings-header-value">${currentValue}</span><select class="avp-settings-select" ${selectAttrs}>${options}</select></div></div>`;
5583
5837
  const currentRate = this._video.playbackRate ?? 1;
5584
- let speedItems = "";
5838
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
5839
+ let speedOpts = "";
5585
5840
  for (const spd of PLAYBACK_SPEEDS) {
5586
- const active = Math.abs(spd - currentRate) < 0.01;
5841
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
5587
5842
  const label = spd === 1 ? "Normal" : `${spd}x`;
5588
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
5843
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
5844
+ }
5845
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5846
+ const audios = this._video.audioTracks ?? [];
5847
+ if (audios.length > 1) {
5848
+ let audioOpts = "";
5849
+ for (const t of audios) {
5850
+ audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5851
+ }
5852
+ sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
5589
5853
  }
5590
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
5591
5854
  const subs = this._video.subtitleTracks ?? [];
5592
5855
  if (subs.length > 0) {
5593
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
5856
+ let subOpts = `<option value="-1" selected>Off</option>`;
5594
5857
  for (const t of subs) {
5595
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5858
+ subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5596
5859
  }
5597
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
5860
+ sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
5598
5861
  }
5599
- const audios = this._video.audioTracks ?? [];
5600
- if (audios.length > 1) {
5601
- let audioItems = "";
5602
- for (const t of audios) {
5603
- audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5862
+ if (this.hasAttribute("show-fit")) {
5863
+ const currentFit = this._video.fit ?? "contain";
5864
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
5865
+ let fitOpts = "";
5866
+ for (const mode of FIT_MODES) {
5867
+ const sel = mode === currentFit ? " selected" : "";
5868
+ const label = mode[0].toUpperCase() + mode.slice(1);
5869
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
5604
5870
  }
5605
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
5871
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
5606
5872
  }
5607
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
5608
- this._settingsMenu.innerHTML = sections.join("");
5609
- for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
5610
- item.addEventListener("click", (e) => {
5611
- e.stopPropagation();
5612
- const mode = item.dataset.fit;
5613
- this.setAttribute("fit", mode);
5614
- this._buildSettingsMenu();
5615
- });
5873
+ for (const cfg of this._customSections) {
5874
+ const activeItem = cfg.items.find((i) => i.active);
5875
+ let customOpts = "";
5876
+ for (const item of cfg.items) {
5877
+ const sel = item.active ? " selected" : "";
5878
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
5879
+ }
5880
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
5616
5881
  }
5617
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
5618
- item.addEventListener("click", (e) => {
5882
+ sections.push(
5883
+ `<div class="avp-settings-section"><div class="avp-settings-header avp-settings-toggle" data-stats><span class="avp-settings-header-label">Stats for Nerds</span></div></div>`
5884
+ );
5885
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
5886
+ this._settingsMenu.innerHTML = "";
5887
+ if (handle) this._settingsMenu.appendChild(handle);
5888
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
5889
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
5890
+ for (const sel of this._settingsMenu.querySelectorAll(".avp-settings-select")) {
5891
+ sel.addEventListener("change", (e) => {
5619
5892
  e.stopPropagation();
5620
- this._video.playbackRate = Number(item.dataset.speed);
5893
+ const action = sel.dataset.action;
5894
+ const val = sel.value;
5895
+ switch (action) {
5896
+ case "speed":
5897
+ this._video.playbackRate = Number(val);
5898
+ break;
5899
+ case "audio":
5900
+ void this._video.setAudioTrack(Number(val));
5901
+ break;
5902
+ case "subtitle":
5903
+ void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
5904
+ break;
5905
+ case "fit":
5906
+ this.setAttribute("fit", val);
5907
+ break;
5908
+ case "custom": {
5909
+ const cfgId = sel.dataset.customId;
5910
+ const cfg = this._customSections.find((s) => s.id === cfgId);
5911
+ cfg?.onSelect(val);
5912
+ break;
5913
+ }
5914
+ }
5621
5915
  this._buildSettingsMenu();
5622
5916
  });
5623
5917
  }
5624
- for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
5625
- item.addEventListener("click", (e) => {
5626
- e.stopPropagation();
5627
- const id = Number(item.dataset.subtitle);
5628
- void this._video.setSubtitleTrack(id >= 0 ? id : null);
5629
- this._closeSettings();
5630
- });
5631
- }
5632
- for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
5633
- item.addEventListener("click", (e) => {
5634
- e.stopPropagation();
5635
- void this._video.setAudioTrack(Number(item.dataset.audio));
5636
- this._closeSettings();
5637
- });
5638
- }
5639
- const statsItem = this._settingsMenu.querySelector("[data-stats]");
5640
- if (statsItem) {
5641
- statsItem.addEventListener("click", (e) => {
5918
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
5919
+ if (statsRow) {
5920
+ statsRow.addEventListener("click", (e) => {
5642
5921
  e.stopPropagation();
5643
5922
  this._toggleStats();
5644
5923
  this._closeSettings();
@@ -5738,19 +6017,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5738
6017
  // it's treated as a double-click and the single-click action is cancelled.
5739
6018
  /** Track whether the last interaction was touch so click handler can skip. */
5740
6019
  _lastPointerTypeWasTouch = false;
5741
- /** True if the event's composed path passes through consumer-slotted toolbar
5742
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5743
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5744
- * does. */
5745
- _isToolbarEvent(e) {
6020
+ /** True if the event's composed path passes through consumer-slotted
6021
+ * content (toolbar or content-overlay). Slotted content lives in the
6022
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
6023
+ * find the shadow-DOM wrapper — `composedPath()` does. */
6024
+ _isSlottedContentEvent(e) {
5746
6025
  for (const node of e.composedPath()) {
5747
- if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
6026
+ if (node instanceof HTMLElement && (node.classList.contains("avp-toolbar-top") || node.classList.contains("avp-content-overlay"))) return true;
5748
6027
  }
5749
6028
  return false;
5750
6029
  }
5751
6030
  _onContainerClick(e) {
5752
6031
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5753
- if (this._isToolbarEvent(e)) return;
6032
+ if (this._isSlottedContentEvent(e)) return;
6033
+ if (this._settingsOpen) {
6034
+ this._closeSettings();
6035
+ return;
6036
+ }
5754
6037
  if (this._lastPointerTypeWasTouch) {
5755
6038
  this._lastPointerTypeWasTouch = false;
5756
6039
  return;
@@ -5766,7 +6049,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5766
6049
  }
5767
6050
  _onContainerDblClick(e) {
5768
6051
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5769
- if (this._isToolbarEvent(e)) return;
6052
+ if (this._isSlottedContentEvent(e)) return;
5770
6053
  if (this._tapTimer) {
5771
6054
  clearTimeout(this._tapTimer);
5772
6055
  this._tapTimer = null;
@@ -5788,7 +6071,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
5788
6071
  if (e.pointerType !== "touch") return;
5789
6072
  this._lastPointerTypeWasTouch = true;
5790
6073
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5791
- if (this._isToolbarEvent(e)) return;
6074
+ if (this._isSlottedContentEvent(e)) return;
6075
+ if (this._settingsOpen) {
6076
+ this._closeSettings();
6077
+ return;
6078
+ }
5792
6079
  const now = Date.now();
5793
6080
  if (now - this._lastTapTime < 300) {
5794
6081
  if (this._tapTimer) {
@@ -6038,6 +6325,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
6038
6325
  async setAudioTrack(id) {
6039
6326
  return this._video.setAudioTrack(id);
6040
6327
  }
6328
+ addSettingsSection(config) {
6329
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
6330
+ this._customSections.push(config);
6331
+ this._buildSettingsMenu();
6332
+ }
6333
+ removeSettingsSection(id) {
6334
+ this._customSections = this._customSections.filter((s) => s.id !== id);
6335
+ this._buildSettingsMenu();
6336
+ }
6041
6337
  async setSubtitleTrack(id) {
6042
6338
  return this._video.setSubtitleTrack(id);
6043
6339
  }