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