avbridge 2.9.0 → 2.11.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 (50) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/{chunk-EY6DZEDT.cjs → chunk-37UOSAVI.cjs} +55 -10
  3. package/dist/chunk-37UOSAVI.cjs.map +1 -0
  4. package/dist/{chunk-5KVLE6YI.js → chunk-EDDWAN2L.js} +3 -2
  5. package/dist/chunk-EDDWAN2L.js.map +1 -0
  6. package/dist/{chunk-SN4WZE24.js → chunk-IHNHHEA2.js} +51 -6
  7. package/dist/chunk-IHNHHEA2.js.map +1 -0
  8. package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
  9. package/dist/chunk-WRKO6Q42.cjs.map +1 -0
  10. package/dist/element-browser.js +63 -4
  11. package/dist/element-browser.js.map +1 -1
  12. package/dist/element.cjs +18 -5
  13. package/dist/element.cjs.map +1 -1
  14. package/dist/element.d.cts +1 -1
  15. package/dist/element.d.ts +1 -1
  16. package/dist/element.js +17 -4
  17. package/dist/element.js.map +1 -1
  18. package/dist/index.cjs +10 -10
  19. package/dist/index.d.cts +2 -2
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -2
  22. package/dist/{player-DEcidWk6.d.cts → player-DDdNVFDv.d.cts} +23 -1
  23. package/dist/{player-DEcidWk6.d.ts → player-DDdNVFDv.d.ts} +23 -1
  24. package/dist/player.cjs +329 -109
  25. package/dist/player.cjs.map +1 -1
  26. package/dist/player.d.cts +42 -0
  27. package/dist/player.d.ts +42 -0
  28. package/dist/player.js +325 -105
  29. package/dist/player.js.map +1 -1
  30. package/dist/subtitles-5H24MEBJ.js +4 -0
  31. package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
  32. package/dist/subtitles-HMVGWTU2.cjs +29 -0
  33. package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
  34. package/package.json +1 -1
  35. package/src/element/avbridge-player.ts +235 -78
  36. package/src/element/avbridge-subtitles.ts +273 -0
  37. package/src/element/avbridge-video.ts +21 -1
  38. package/src/element/player-styles.ts +85 -35
  39. package/src/index.ts +1 -0
  40. package/src/strategies/fallback/audio-output.ts +39 -4
  41. package/src/strategies/fallback/index.ts +12 -0
  42. package/src/strategies/hybrid/index.ts +9 -0
  43. package/src/subtitles/index.ts +2 -0
  44. package/src/types.ts +25 -0
  45. package/dist/chunk-5KVLE6YI.js.map +0 -1
  46. package/dist/chunk-EY6DZEDT.cjs.map +0 -1
  47. package/dist/chunk-S4WAZC2T.cjs.map +0 -1
  48. package/dist/chunk-SN4WZE24.js.map +0 -1
  49. package/dist/subtitles-4T74JRGT.js +0 -4
  50. package/dist/subtitles-QUH4LPI4.cjs +0 -29
package/dist/player.cjs CHANGED
@@ -3,7 +3,7 @@
3
3
  var chunk2XW2O3YI_cjs = require('./chunk-2XW2O3YI.cjs');
4
4
  var chunkNNVOHKXJ_cjs = require('./chunk-NNVOHKXJ.cjs');
5
5
  require('./chunk-Z33SBWL5.cjs');
6
- var chunkS4WAZC2T_cjs = require('./chunk-S4WAZC2T.cjs');
6
+ var chunkWRKO6Q42_cjs = require('./chunk-WRKO6Q42.cjs');
7
7
  require('./chunk-QDJLQR53.cjs');
8
8
 
9
9
  // src/events.ts
@@ -1286,7 +1286,7 @@ var VideoRenderer = class {
1286
1286
  }
1287
1287
  target.style.visibility = "hidden";
1288
1288
  const overlayParent = parent instanceof HTMLElement ? parent : document.body;
1289
- this.subtitleOverlay = new chunkS4WAZC2T_cjs.SubtitleOverlay(overlayParent);
1289
+ this.subtitleOverlay = new chunkWRKO6Q42_cjs.SubtitleOverlay(overlayParent);
1290
1290
  this.watchTextTracks(target);
1291
1291
  const ctx = this.canvas.getContext("2d");
1292
1292
  if (!ctx) throw new Error("video renderer: failed to acquire 2D context");
@@ -1607,6 +1607,10 @@ var AudioOutput = class {
1607
1607
  _volume = 1;
1608
1608
  /** User-set muted flag. When true, gain is forced to 0. */
1609
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;
1610
1614
  constructor() {
1611
1615
  this.ctx = new AudioContext();
1612
1616
  this.gain = this.ctx.createGain();
@@ -1628,6 +1632,20 @@ var AudioOutput = class {
1628
1632
  getMuted() {
1629
1633
  return this._muted;
1630
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
+ }
1631
1649
  applyGain() {
1632
1650
  const target = this._muted ? 0 : this._volume;
1633
1651
  try {
@@ -1648,12 +1666,12 @@ var AudioOutput = class {
1648
1666
  now() {
1649
1667
  if (this.noAudio) {
1650
1668
  if (this.state === "playing") {
1651
- return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3;
1669
+ return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3 * this._rate;
1652
1670
  }
1653
1671
  return this.mediaTimeOfAnchor;
1654
1672
  }
1655
1673
  if (this.state === "playing") {
1656
- return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
1674
+ return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1657
1675
  }
1658
1676
  return this.mediaTimeOfAnchor;
1659
1677
  }
@@ -1705,7 +1723,8 @@ var AudioOutput = class {
1705
1723
  const node = this.ctx.createBufferSource();
1706
1724
  node.buffer = buffer;
1707
1725
  node.connect(this.gain);
1708
- 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;
1709
1728
  if (ctxStart < this.ctx.currentTime) {
1710
1729
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1711
1730
  this.mediaTimeOfAnchor = this.mediaTimeOfNext;
@@ -1732,6 +1751,10 @@ var AudioOutput = class {
1732
1751
  if (this.ctx.state === "suspended") {
1733
1752
  await this.ctx.resume();
1734
1753
  }
1754
+ try {
1755
+ this.gain.connect(this.ctx.destination);
1756
+ } catch {
1757
+ }
1735
1758
  if (this.state === "paused") {
1736
1759
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1737
1760
  this.state = "playing";
@@ -1758,6 +1781,10 @@ var AudioOutput = class {
1758
1781
  this.mediaTimeOfAnchor = this.now();
1759
1782
  this.state = "paused";
1760
1783
  if (this.noAudio) return;
1784
+ try {
1785
+ this.gain.disconnect();
1786
+ } catch {
1787
+ }
1761
1788
  if (this.ctx.state === "running") {
1762
1789
  await this.ctx.suspend();
1763
1790
  }
@@ -2533,6 +2560,14 @@ async function createHybridSession(ctx, target, transport) {
2533
2560
  get: () => ctx.duration ?? NaN
2534
2561
  });
2535
2562
  }
2563
+ Object.defineProperty(target, "playbackRate", {
2564
+ configurable: true,
2565
+ get: () => audio.getPlaybackRate(),
2566
+ set: (v) => {
2567
+ audio.setPlaybackRate(v);
2568
+ target.dispatchEvent(new Event("ratechange"));
2569
+ }
2570
+ });
2536
2571
  Object.defineProperty(target, "readyState", {
2537
2572
  configurable: true,
2538
2573
  get: () => {
@@ -2643,6 +2678,7 @@ async function createHybridSession(ctx, target, transport) {
2643
2678
  delete target.muted;
2644
2679
  delete target.readyState;
2645
2680
  delete target.seekable;
2681
+ delete target.playbackRate;
2646
2682
  } catch {
2647
2683
  }
2648
2684
  },
@@ -3178,6 +3214,14 @@ async function createFallbackSession(ctx, target, transport) {
3178
3214
  get: () => ctx.duration ?? NaN
3179
3215
  });
3180
3216
  }
3217
+ Object.defineProperty(target, "playbackRate", {
3218
+ configurable: true,
3219
+ get: () => audio.getPlaybackRate(),
3220
+ set: (v) => {
3221
+ audio.setPlaybackRate(v);
3222
+ target.dispatchEvent(new Event("ratechange"));
3223
+ }
3224
+ });
3181
3225
  Object.defineProperty(target, "readyState", {
3182
3226
  configurable: true,
3183
3227
  get: () => {
@@ -3309,6 +3353,7 @@ async function createFallbackSession(ctx, target, transport) {
3309
3353
  delete target.muted;
3310
3354
  delete target.readyState;
3311
3355
  delete target.seekable;
3356
+ delete target.playbackRate;
3312
3357
  } catch {
3313
3358
  }
3314
3359
  },
@@ -3419,7 +3464,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3419
3464
  switchingPromise = Promise.resolve();
3420
3465
  // Owns blob URLs created during sidecar discovery + SRT->VTT conversion.
3421
3466
  // Revoked at destroy() so repeated source swaps don't leak.
3422
- subtitleResources = new chunkS4WAZC2T_cjs.SubtitleResourceBag();
3467
+ subtitleResources = new chunkWRKO6Q42_cjs.SubtitleResourceBag();
3423
3468
  // Transport config extracted from CreatePlayerOptions. Threaded to probe,
3424
3469
  // subtitle fetches, and strategy session creators. Not stored on MediaContext
3425
3470
  // because it's runtime config, not media analysis.
@@ -3465,7 +3510,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3465
3510
  }
3466
3511
  }
3467
3512
  if (this.options.directory && this.options.source instanceof File) {
3468
- const found = await chunkS4WAZC2T_cjs.discoverSidecars(this.options.source, this.options.directory);
3513
+ const found = await chunkWRKO6Q42_cjs.discoverSidecars(this.options.source, this.options.directory);
3469
3514
  for (const s of found) {
3470
3515
  this.subtitleResources.track(s.url);
3471
3516
  ctx.subtitleTracks.push({
@@ -3488,7 +3533,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
3488
3533
  reason: decision.reason
3489
3534
  });
3490
3535
  await this.startSession(decision.strategy, decision.reason);
3491
- await chunkS4WAZC2T_cjs.attachSubtitleTracks(
3536
+ await chunkWRKO6Q42_cjs.attachSubtitleTracks(
3492
3537
  this.options.target,
3493
3538
  ctx.subtitleTracks,
3494
3539
  this.subtitleResources,
@@ -4530,7 +4575,7 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4530
4575
  * strategies pick up the new track via their textTracks watcher.
4531
4576
  */
4532
4577
  async addSubtitle(subtitle) {
4533
- const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-QUH4LPI4.cjs');
4578
+ const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-HMVGWTU2.cjs');
4534
4579
  const format = subtitle.format ?? (subtitle.url.endsWith(".srt") ? "srt" : "vtt");
4535
4580
  const track = {
4536
4581
  id: this._subtitleTracks.length,
@@ -4539,14 +4584,27 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4539
4584
  sidecarUrl: subtitle.url
4540
4585
  };
4541
4586
  this._subtitleTracks.push(track);
4587
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
4542
4588
  await attachSubtitleTracks2(
4543
4589
  this._videoEl,
4544
4590
  this._subtitleTracks,
4545
4591
  void 0,
4546
4592
  (err, t) => {
4547
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
4593
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
4548
4594
  }
4549
4595
  );
4596
+ const textTracks = this._videoEl.textTracks;
4597
+ for (let i = 0; i < textTracks.length; i++) {
4598
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
4599
+ textTracks[i].mode = "showing";
4600
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
4601
+ break;
4602
+ }
4603
+ }
4604
+ this._dispatch("trackschange", {
4605
+ audioTracks: this._audioTracks,
4606
+ subtitleTracks: this.subtitleTracks
4607
+ });
4550
4608
  }
4551
4609
  /**
4552
4610
  * Disable the automatic `screen.orientation.lock()` that runs on
@@ -4725,7 +4783,6 @@ var PLAYER_STYLES = (
4725
4783
  position: relative;
4726
4784
  width: 100%;
4727
4785
  height: 100%;
4728
- cursor: pointer;
4729
4786
  -webkit-tap-highlight-color: transparent;
4730
4787
  user-select: none;
4731
4788
  }
@@ -5175,62 +5232,113 @@ var PLAYER_STYLES = (
5175
5232
 
5176
5233
  .avp-spacer { flex: 1; }
5177
5234
 
5178
- /* \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 */
5235
+ /* \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 */
5236
+
5237
+ /* Scrim \u2014 semi-transparent overlay behind the sheet, above the video.
5238
+ Tapping it dismisses the sheet. */
5239
+ .avp-settings-scrim {
5240
+ position: absolute;
5241
+ inset: 0;
5242
+ z-index: 9;
5243
+ background: rgba(0, 0, 0, 0.4);
5244
+ opacity: 0;
5245
+ pointer-events: none;
5246
+ transition: opacity 0.2s;
5247
+ }
5179
5248
 
5249
+ .avp-settings-scrim.open {
5250
+ opacity: 1;
5251
+ pointer-events: auto;
5252
+ }
5253
+
5254
+ /* Sheet container \u2014 slides up from the bottom. Height is content-driven
5255
+ up to a JS-measured max (set on open via style.maxHeight). */
5180
5256
  .avp-settings {
5181
5257
  position: absolute;
5182
- bottom: 52px;
5183
- right: 12px;
5184
- background: rgba(28, 28, 28, 0.95);
5185
- border-radius: 8px;
5186
- min-width: 220px;
5187
- /* Fit within the player: leave room for the controls bar (52px bottom)
5188
- and a small top margin (8px). On tall players this caps at 300px;
5189
- on short players it shrinks to whatever fits. */
5190
- max-height: min(300px, calc(100% - 52px - 8px));
5191
- overflow-y: auto;
5192
- display: none;
5258
+ bottom: 0;
5259
+ left: 0;
5260
+ right: 0;
5193
5261
  z-index: 10;
5194
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
5262
+ background: rgba(28, 28, 28, 0.97);
5263
+ border-radius: 12px 12px 0 0;
5264
+ overflow-y: auto;
5265
+ overscroll-behavior: contain;
5266
+ transform: translateY(100%);
5267
+ transition: transform 0.2s ease-out;
5268
+ max-height: 70%;
5269
+ padding-bottom: 52px;
5270
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
5195
5271
  }
5196
5272
 
5197
- .avp-settings.open { display: block; }
5273
+ .avp-settings.open {
5274
+ transform: translateY(0);
5275
+ }
5198
5276
 
5199
- .avp-settings-section {
5200
- padding: 8px 0;
5201
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
5277
+ /* Drag handle indicator at top of sheet. */
5278
+ .avp-settings-handle {
5279
+ width: 36px;
5280
+ height: 4px;
5281
+ border-radius: 2px;
5282
+ background: rgba(255, 255, 255, 0.3);
5283
+ margin: 8px auto 4px;
5202
5284
  }
5203
5285
 
5204
- .avp-settings-section:last-child { border-bottom: none; }
5286
+ /* \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 */
5205
5287
 
5206
- .avp-settings-label {
5207
- padding: 4px 16px;
5208
- font-size: 11px;
5209
- text-transform: uppercase;
5210
- letter-spacing: 0.5px;
5211
- opacity: 0.5;
5288
+ .avp-settings-section {
5289
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
5212
5290
  }
5213
5291
 
5214
- .avp-settings-item {
5292
+ .avp-settings-section:last-child { border-bottom: none; }
5293
+
5294
+ /* Section header \u2014 clickable row showing label + current value. */
5295
+ .avp-settings-header {
5296
+ position: relative;
5215
5297
  display: flex;
5216
5298
  align-items: center;
5217
- padding: 8px 16px;
5218
- font-size: 13px;
5299
+ justify-content: space-between;
5300
+ padding: 12px 16px;
5219
5301
  cursor: pointer;
5302
+ font-size: 14px;
5220
5303
  transition: background 0.1s;
5221
5304
  }
5222
5305
 
5223
- .avp-settings-item:hover { background: rgba(255, 255, 255, 0.1); }
5306
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
5307
+
5308
+ .avp-settings-header-label {
5309
+ display: flex;
5310
+ align-items: center;
5311
+ gap: 8px;
5312
+ font-weight: 500;
5313
+ }
5314
+
5315
+ .avp-settings-header-value {
5316
+ margin-left: auto;
5317
+ opacity: 0.6;
5318
+ font-size: 13px;
5319
+ text-align: right;
5320
+ }
5224
5321
 
5225
- .avp-settings-item.active {
5226
- color: #3ea6ff;
5322
+ /* Invisible native <select> layered over the value portion of the row.
5323
+ Covers from the value text to the right edge so tapping the value
5324
+ opens the OS picker. The label side remains inert. */
5325
+ .avp-settings-select {
5326
+ position: absolute;
5327
+ top: 0;
5328
+ right: 0;
5329
+ bottom: 0;
5330
+ width: 50%;
5331
+ opacity: 0;
5332
+ cursor: pointer;
5333
+ font-size: 16px;
5334
+ direction: rtl;
5227
5335
  }
5228
5336
 
5229
- .avp-settings-item.active::before {
5230
- content: "\\2713";
5231
- margin-right: 8px;
5232
- font-weight: bold;
5337
+ /* Toggle-style rows (Stats for Nerds) \u2014 no select, just clickable. */
5338
+ .avp-settings-toggle {
5339
+ cursor: pointer;
5233
5340
  }
5341
+ .avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
5234
5342
 
5235
5343
  /* \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 */
5236
5344
 
@@ -5310,7 +5418,7 @@ function formatTime(sec) {
5310
5418
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
5311
5419
  }
5312
5420
  var PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
5313
- var CONTROLS_HIDE_MS = 3e3;
5421
+ var DEFAULT_CONTROLS_HIDE_MS = 3e3;
5314
5422
  var FORWARDED_EVENTS = [
5315
5423
  "ready",
5316
5424
  "error",
@@ -5353,7 +5461,7 @@ var PROXY_ATTRIBUTES = [
5353
5461
  ];
5354
5462
  var PLAYER_ATTRIBUTES = ["show-fit"];
5355
5463
  var FIT_MODES = ["contain", "cover", "fill"];
5356
- var AvbridgePlayerElement = class extends HTMLElement {
5464
+ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5357
5465
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5358
5466
  // ── Internal DOM refs ──────────────────────────────────────────────────
5359
5467
  _video;
@@ -5369,6 +5477,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5369
5477
  _volumeInput;
5370
5478
  _settingsBtn;
5371
5479
  _settingsMenu;
5480
+ _settingsScrim;
5481
+ _customSections = [];
5372
5482
  _fullscreenBtn;
5373
5483
  // Strategy badge removed — visible in Stats for Nerds instead.
5374
5484
  // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
@@ -5379,6 +5489,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5379
5489
  _state = "idle";
5380
5490
  _controlsTimer = null;
5381
5491
  _settingsOpen = false;
5492
+ _activeAudioTrackId = null;
5493
+ _activeSubtitleTrackId = null;
5382
5494
  _userSeeking = false;
5383
5495
  _holdTimer = null;
5384
5496
  _holdSpeedActive = false;
@@ -5410,6 +5522,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5410
5522
  this._volumeInput = shadow.querySelector(".avp-volume-input");
5411
5523
  this._settingsBtn = shadow.querySelector(".avp-settings-btn");
5412
5524
  this._settingsMenu = shadow.querySelector(".avp-settings");
5525
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim");
5413
5526
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen");
5414
5527
  this._speedIndicator = shadow.querySelector(".avp-speed-indicator");
5415
5528
  this._statsEl = shadow.querySelector(".avp-stats");
@@ -5466,7 +5579,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5466
5579
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
5467
5580
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
5468
5581
  </div>
5469
- <div class="avp-settings" part="settings-menu"></div>
5582
+ <div class="avp-settings-scrim"></div>
5583
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
5470
5584
  </div>
5471
5585
  </div>`;
5472
5586
  }
@@ -5538,6 +5652,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5538
5652
  e.stopPropagation();
5539
5653
  this._toggleSettings();
5540
5654
  });
5655
+ on(this._settingsScrim, "click", () => this._closeSettings());
5541
5656
  on(this._fullscreenBtn, "click", (e) => {
5542
5657
  e.stopPropagation();
5543
5658
  this._toggleFullscreen();
@@ -5546,11 +5661,6 @@ var AvbridgePlayerElement = class extends HTMLElement {
5546
5661
  const container = this.shadowRoot.querySelector(".avp");
5547
5662
  on(container, "click", (e) => this._onContainerClick(e));
5548
5663
  on(container, "dblclick", (e) => this._onContainerDblClick(e));
5549
- on(container, "click", (e) => {
5550
- if (this._settingsOpen && !e.target.closest?.(".avp-settings-btn, .avp-settings")) {
5551
- this._closeSettings();
5552
- }
5553
- });
5554
5664
  on(document, "click", (e) => {
5555
5665
  if (this._settingsOpen && !this.contains(e.target)) {
5556
5666
  this._closeSettings();
@@ -5644,6 +5754,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5644
5754
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5645
5755
  return frac * (this._video.duration || 0);
5646
5756
  }
5757
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5758
+ * preview-only). On narrow bars precise positioning is hard, so
5759
+ * immediate video feedback is more useful than a time tooltip. */
5760
+ static SCRUB_WIDTH_THRESHOLD = 400;
5647
5761
  _onSeekPointerDown(e) {
5648
5762
  if (e.button !== 0 && e.pointerType === "mouse") return;
5649
5763
  e.preventDefault();
@@ -5651,15 +5765,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5651
5765
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5652
5766
  seekBar.setPointerCapture(e.pointerId);
5653
5767
  seekBar.setAttribute("data-seeking", "");
5768
+ const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5769
+ let lastScrubCommit = 0;
5654
5770
  const initial = this._timeFromSeekPointer(e.clientX);
5655
5771
  this._seekInput.value = String(initial);
5656
5772
  this._onSeekInput();
5657
5773
  this._updateSeekTooltip(e.clientX);
5774
+ if (scrubMode) this._onSeekCommit();
5658
5775
  const onMove = (ev) => {
5659
5776
  const t = this._timeFromSeekPointer(ev.clientX);
5660
5777
  this._seekInput.value = String(t);
5661
5778
  this._onSeekInput();
5662
5779
  this._updateSeekTooltip(ev.clientX);
5780
+ if (scrubMode) {
5781
+ const now = performance.now();
5782
+ if (now - lastScrubCommit > 250) {
5783
+ lastScrubCommit = now;
5784
+ this._onSeekCommit();
5785
+ this._userSeeking = true;
5786
+ }
5787
+ }
5663
5788
  };
5664
5789
  const onUp = (ev) => {
5665
5790
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -5728,83 +5853,123 @@ var AvbridgePlayerElement = class extends HTMLElement {
5728
5853
  _toggleSettings() {
5729
5854
  this._settingsOpen = !this._settingsOpen;
5730
5855
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
5731
- if (this._settingsOpen) this._showControls();
5856
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
5857
+ if (this._settingsOpen) {
5858
+ this._fitSettingsToPlayer();
5859
+ this._showControls();
5860
+ }
5861
+ }
5862
+ _fitSettingsToPlayer() {
5863
+ const container = this.shadowRoot?.querySelector(".avp");
5864
+ if (!container) return;
5865
+ const rect = container.getBoundingClientRect();
5866
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
5867
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
5732
5868
  }
5733
5869
  _closeSettings() {
5734
5870
  this._settingsOpen = false;
5735
5871
  this._settingsMenu.classList.remove("open");
5872
+ this._settingsScrim.classList.remove("open");
5736
5873
  }
5737
5874
  _buildSettingsMenu() {
5738
5875
  const sections = [];
5739
- if (this.hasAttribute("show-fit")) {
5740
- const currentFit = this._video.fit ?? "contain";
5741
- let fitItems = "";
5742
- for (const mode of FIT_MODES) {
5743
- const active = mode === currentFit;
5744
- const label = mode[0].toUpperCase() + mode.slice(1);
5745
- fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
5746
- }
5747
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
5748
- }
5876
+ 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>`;
5749
5877
  const currentRate = this._video.playbackRate ?? 1;
5750
- let speedItems = "";
5878
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
5879
+ let speedOpts = "";
5751
5880
  for (const spd of PLAYBACK_SPEEDS) {
5752
- const active = Math.abs(spd - currentRate) < 0.01;
5881
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
5753
5882
  const label = spd === 1 ? "Normal" : `${spd}x`;
5754
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
5883
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
5884
+ }
5885
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5886
+ const audios = this._video.audioTracks ?? [];
5887
+ if (audios.length > 1) {
5888
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
5889
+ const activeAudio = audios.find((t) => t.id === activeAudioId) ?? audios[0];
5890
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
5891
+ let audioOpts = "";
5892
+ for (const t of audios) {
5893
+ const sel = t.id === activeAudioId ? " selected" : "";
5894
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5895
+ }
5896
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
5755
5897
  }
5756
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
5757
5898
  const subs = this._video.subtitleTracks ?? [];
5758
5899
  if (subs.length > 0) {
5759
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
5900
+ const activeSubId = this._activeSubtitleTrackId;
5901
+ const activeSub = activeSubId != null ? subs.find((t) => t.id === activeSubId) : null;
5902
+ const subValue = activeSub ? activeSub.language ?? `Track ${activeSub.id}` : "Off";
5903
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
5760
5904
  for (const t of subs) {
5761
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5905
+ const sel = t.id === activeSubId ? " selected" : "";
5906
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5762
5907
  }
5763
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
5908
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
5764
5909
  }
5765
- const audios = this._video.audioTracks ?? [];
5766
- if (audios.length > 1) {
5767
- let audioItems = "";
5768
- for (const t of audios) {
5769
- audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5910
+ if (this.hasAttribute("show-fit")) {
5911
+ const currentFit = this._video.fit ?? "contain";
5912
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
5913
+ let fitOpts = "";
5914
+ for (const mode of FIT_MODES) {
5915
+ const sel = mode === currentFit ? " selected" : "";
5916
+ const label = mode[0].toUpperCase() + mode.slice(1);
5917
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
5770
5918
  }
5771
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
5919
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
5772
5920
  }
5773
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
5774
- this._settingsMenu.innerHTML = sections.join("");
5775
- for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
5776
- item.addEventListener("click", (e) => {
5777
- e.stopPropagation();
5778
- const mode = item.dataset.fit;
5779
- this.setAttribute("fit", mode);
5780
- this._buildSettingsMenu();
5781
- });
5921
+ for (const cfg of this._customSections) {
5922
+ const activeItem = cfg.items.find((i) => i.active);
5923
+ let customOpts = "";
5924
+ for (const item of cfg.items) {
5925
+ const sel = item.active ? " selected" : "";
5926
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
5927
+ }
5928
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
5782
5929
  }
5783
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
5784
- item.addEventListener("click", (e) => {
5930
+ sections.push(
5931
+ `<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>`
5932
+ );
5933
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
5934
+ this._settingsMenu.innerHTML = "";
5935
+ if (handle) this._settingsMenu.appendChild(handle);
5936
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
5937
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
5938
+ for (const sel of this._settingsMenu.querySelectorAll(".avp-settings-select")) {
5939
+ sel.addEventListener("change", (e) => {
5785
5940
  e.stopPropagation();
5786
- this._video.playbackRate = Number(item.dataset.speed);
5941
+ const action = sel.dataset.action;
5942
+ const val = sel.value;
5943
+ switch (action) {
5944
+ case "speed":
5945
+ this._video.playbackRate = Number(val);
5946
+ break;
5947
+ case "audio":
5948
+ this._activeAudioTrackId = Number(val);
5949
+ void this._video.setAudioTrack(Number(val));
5950
+ break;
5951
+ case "subtitle": {
5952
+ const subId = Number(val);
5953
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
5954
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
5955
+ break;
5956
+ }
5957
+ case "fit":
5958
+ this.setAttribute("fit", val);
5959
+ break;
5960
+ case "custom": {
5961
+ const cfgId = sel.dataset.customId;
5962
+ const cfg = this._customSections.find((s) => s.id === cfgId);
5963
+ cfg?.onSelect(val);
5964
+ break;
5965
+ }
5966
+ }
5787
5967
  this._buildSettingsMenu();
5788
5968
  });
5789
5969
  }
5790
- for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
5791
- item.addEventListener("click", (e) => {
5792
- e.stopPropagation();
5793
- const id = Number(item.dataset.subtitle);
5794
- void this._video.setSubtitleTrack(id >= 0 ? id : null);
5795
- this._closeSettings();
5796
- });
5797
- }
5798
- for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
5799
- item.addEventListener("click", (e) => {
5800
- e.stopPropagation();
5801
- void this._video.setAudioTrack(Number(item.dataset.audio));
5802
- this._closeSettings();
5803
- });
5804
- }
5805
- const statsItem = this._settingsMenu.querySelector("[data-stats]");
5806
- if (statsItem) {
5807
- statsItem.addEventListener("click", (e) => {
5970
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
5971
+ if (statsRow) {
5972
+ statsRow.addEventListener("click", (e) => {
5808
5973
  e.stopPropagation();
5809
5974
  this._toggleStats();
5810
5975
  this._closeSettings();
@@ -5883,16 +6048,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5883
6048
  _showControls() {
5884
6049
  this.showControls();
5885
6050
  }
5886
- _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
6051
+ _scheduleHide(durationMs) {
6052
+ const ms = durationMs ?? this._getControlsTimeout();
5887
6053
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
5888
6054
  if (this._state !== "playing" && this._state !== "buffering") return;
5889
6055
  if (this._settingsOpen) return;
6056
+ if (ms <= 0) return;
5890
6057
  this._controlsTimer = setTimeout(() => {
5891
6058
  if (this._state === "playing") {
5892
6059
  this.setAttribute("data-controls-hidden", "");
5893
6060
  this._toolbarTop.setAttribute("data-visible", "false");
5894
6061
  }
5895
- }, durationMs);
6062
+ }, ms);
6063
+ }
6064
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
6065
+ * Unset = default 3000ms. */
6066
+ _getControlsTimeout() {
6067
+ const attr = this.getAttribute("controls-timeout");
6068
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
6069
+ const n = Number(attr);
6070
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
5896
6071
  }
5897
6072
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
5898
6073
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -5904,6 +6079,9 @@ var AvbridgePlayerElement = class extends HTMLElement {
5904
6079
  // it's treated as a double-click and the single-click action is cancelled.
5905
6080
  /** Track whether the last interaction was touch so click handler can skip. */
5906
6081
  _lastPointerTypeWasTouch = false;
6082
+ /** True for ~50ms after a touch double-tap was handled, so the
6083
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
6084
+ _touchDoubleTapConsumed = false;
5907
6085
  /** True if the event's composed path passes through consumer-slotted
5908
6086
  * content (toolbar or content-overlay). Slotted content lives in the
5909
6087
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -5917,6 +6095,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5917
6095
  _onContainerClick(e) {
5918
6096
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5919
6097
  if (this._isSlottedContentEvent(e)) return;
6098
+ if (this._settingsOpen) {
6099
+ this._closeSettings();
6100
+ return;
6101
+ }
5920
6102
  if (this._lastPointerTypeWasTouch) {
5921
6103
  this._lastPointerTypeWasTouch = false;
5922
6104
  return;
@@ -5933,6 +6115,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5933
6115
  _onContainerDblClick(e) {
5934
6116
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5935
6117
  if (this._isSlottedContentEvent(e)) return;
6118
+ if (this._touchDoubleTapConsumed) return;
5936
6119
  if (this._tapTimer) {
5937
6120
  clearTimeout(this._tapTimer);
5938
6121
  this._tapTimer = null;
@@ -5955,6 +6138,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5955
6138
  this._lastPointerTypeWasTouch = true;
5956
6139
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5957
6140
  if (this._isSlottedContentEvent(e)) return;
6141
+ if (this._settingsOpen) {
6142
+ this._closeSettings();
6143
+ return;
6144
+ }
5958
6145
  const now = Date.now();
5959
6146
  if (now - this._lastTapTime < 300) {
5960
6147
  if (this._tapTimer) {
@@ -5970,6 +6157,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5970
6157
  } else {
5971
6158
  this._toggleFullscreen();
5972
6159
  }
6160
+ this._touchDoubleTapConsumed = true;
6161
+ setTimeout(() => {
6162
+ this._touchDoubleTapConsumed = false;
6163
+ }, 100);
5973
6164
  this._lastTapTime = 0;
5974
6165
  return;
5975
6166
  }
@@ -6003,6 +6194,13 @@ var AvbridgePlayerElement = class extends HTMLElement {
6003
6194
  this._video.currentTime = Math.max(0, this._video.currentTime + delta);
6004
6195
  }
6005
6196
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
6197
+ /** Duration of one frame in seconds, derived from diagnostics fps or
6198
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
6199
+ _frameDuration() {
6200
+ const diag = this._video.getDiagnostics();
6201
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
6202
+ return 1 / fps;
6203
+ }
6006
6204
  _onKeydown(e) {
6007
6205
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
6008
6206
  switch (e.key) {
@@ -6047,6 +6245,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
6047
6245
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
6048
6246
  this._buildSettingsMenu();
6049
6247
  break;
6248
+ case ",":
6249
+ e.preventDefault();
6250
+ if (!this._video.paused) this._video.pause();
6251
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
6252
+ break;
6253
+ case ".":
6254
+ e.preventDefault();
6255
+ if (!this._video.paused) this._video.pause();
6256
+ this._video.currentTime = Math.min(
6257
+ this._video.duration || 0,
6258
+ this._video.currentTime + this._frameDuration()
6259
+ );
6260
+ break;
6050
6261
  case "Escape":
6051
6262
  if (this._settingsOpen) {
6052
6263
  e.preventDefault();
@@ -6204,6 +6415,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
6204
6415
  async setAudioTrack(id) {
6205
6416
  return this._video.setAudioTrack(id);
6206
6417
  }
6418
+ addSettingsSection(config) {
6419
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
6420
+ this._customSections.push(config);
6421
+ this._buildSettingsMenu();
6422
+ }
6423
+ removeSettingsSection(id) {
6424
+ this._customSections = this._customSections.filter((s) => s.id !== id);
6425
+ this._buildSettingsMenu();
6426
+ }
6207
6427
  async setSubtitleTrack(id) {
6208
6428
  return this._video.setSubtitleTrack(id);
6209
6429
  }