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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AvbridgeError, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, normalizeSource, sniffNormalizedSource, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_LIBAV_NOT_REACHABLE, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from './chunk-E76AMWI4.js';
2
2
  import { dbg, loadLibav } from './chunk-IAYKFGFG.js';
3
3
  import './chunk-DCSOQH2N.js';
4
- import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-5KVLE6YI.js';
4
+ import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-EDDWAN2L.js';
5
5
  import './chunk-LUFA47FP.js';
6
6
 
7
7
  // src/events.ts
@@ -1605,6 +1605,10 @@ var AudioOutput = class {
1605
1605
  _volume = 1;
1606
1606
  /** User-set muted flag. When true, gain is forced to 0. */
1607
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;
1608
1612
  constructor() {
1609
1613
  this.ctx = new AudioContext();
1610
1614
  this.gain = this.ctx.createGain();
@@ -1626,6 +1630,20 @@ var AudioOutput = class {
1626
1630
  getMuted() {
1627
1631
  return this._muted;
1628
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
+ }
1629
1647
  applyGain() {
1630
1648
  const target = this._muted ? 0 : this._volume;
1631
1649
  try {
@@ -1646,12 +1664,12 @@ var AudioOutput = class {
1646
1664
  now() {
1647
1665
  if (this.noAudio) {
1648
1666
  if (this.state === "playing") {
1649
- return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3;
1667
+ return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1e3 * this._rate;
1650
1668
  }
1651
1669
  return this.mediaTimeOfAnchor;
1652
1670
  }
1653
1671
  if (this.state === "playing") {
1654
- return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
1672
+ return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1655
1673
  }
1656
1674
  return this.mediaTimeOfAnchor;
1657
1675
  }
@@ -1703,7 +1721,8 @@ var AudioOutput = class {
1703
1721
  const node = this.ctx.createBufferSource();
1704
1722
  node.buffer = buffer;
1705
1723
  node.connect(this.gain);
1706
- 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;
1707
1726
  if (ctxStart < this.ctx.currentTime) {
1708
1727
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1709
1728
  this.mediaTimeOfAnchor = this.mediaTimeOfNext;
@@ -1730,6 +1749,10 @@ var AudioOutput = class {
1730
1749
  if (this.ctx.state === "suspended") {
1731
1750
  await this.ctx.resume();
1732
1751
  }
1752
+ try {
1753
+ this.gain.connect(this.ctx.destination);
1754
+ } catch {
1755
+ }
1733
1756
  if (this.state === "paused") {
1734
1757
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1735
1758
  this.state = "playing";
@@ -1756,6 +1779,10 @@ var AudioOutput = class {
1756
1779
  this.mediaTimeOfAnchor = this.now();
1757
1780
  this.state = "paused";
1758
1781
  if (this.noAudio) return;
1782
+ try {
1783
+ this.gain.disconnect();
1784
+ } catch {
1785
+ }
1759
1786
  if (this.ctx.state === "running") {
1760
1787
  await this.ctx.suspend();
1761
1788
  }
@@ -2531,6 +2558,14 @@ async function createHybridSession(ctx, target, transport) {
2531
2558
  get: () => ctx.duration ?? NaN
2532
2559
  });
2533
2560
  }
2561
+ Object.defineProperty(target, "playbackRate", {
2562
+ configurable: true,
2563
+ get: () => audio.getPlaybackRate(),
2564
+ set: (v) => {
2565
+ audio.setPlaybackRate(v);
2566
+ target.dispatchEvent(new Event("ratechange"));
2567
+ }
2568
+ });
2534
2569
  Object.defineProperty(target, "readyState", {
2535
2570
  configurable: true,
2536
2571
  get: () => {
@@ -2641,6 +2676,7 @@ async function createHybridSession(ctx, target, transport) {
2641
2676
  delete target.muted;
2642
2677
  delete target.readyState;
2643
2678
  delete target.seekable;
2679
+ delete target.playbackRate;
2644
2680
  } catch {
2645
2681
  }
2646
2682
  },
@@ -3176,6 +3212,14 @@ async function createFallbackSession(ctx, target, transport) {
3176
3212
  get: () => ctx.duration ?? NaN
3177
3213
  });
3178
3214
  }
3215
+ Object.defineProperty(target, "playbackRate", {
3216
+ configurable: true,
3217
+ get: () => audio.getPlaybackRate(),
3218
+ set: (v) => {
3219
+ audio.setPlaybackRate(v);
3220
+ target.dispatchEvent(new Event("ratechange"));
3221
+ }
3222
+ });
3179
3223
  Object.defineProperty(target, "readyState", {
3180
3224
  configurable: true,
3181
3225
  get: () => {
@@ -3307,6 +3351,7 @@ async function createFallbackSession(ctx, target, transport) {
3307
3351
  delete target.muted;
3308
3352
  delete target.readyState;
3309
3353
  delete target.seekable;
3354
+ delete target.playbackRate;
3310
3355
  } catch {
3311
3356
  }
3312
3357
  },
@@ -4528,7 +4573,7 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4528
4573
  * strategies pick up the new track via their textTracks watcher.
4529
4574
  */
4530
4575
  async addSubtitle(subtitle) {
4531
- const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-4T74JRGT.js');
4576
+ const { attachSubtitleTracks: attachSubtitleTracks2 } = await import('./subtitles-5H24MEBJ.js');
4532
4577
  const format = subtitle.format ?? (subtitle.url.endsWith(".srt") ? "srt" : "vtt");
4533
4578
  const track = {
4534
4579
  id: this._subtitleTracks.length,
@@ -4537,14 +4582,27 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4537
4582
  sidecarUrl: subtitle.url
4538
4583
  };
4539
4584
  this._subtitleTracks.push(track);
4585
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
4540
4586
  await attachSubtitleTracks2(
4541
4587
  this._videoEl,
4542
4588
  this._subtitleTracks,
4543
4589
  void 0,
4544
4590
  (err, t) => {
4545
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
4591
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
4546
4592
  }
4547
4593
  );
4594
+ const textTracks = this._videoEl.textTracks;
4595
+ for (let i = 0; i < textTracks.length; i++) {
4596
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
4597
+ textTracks[i].mode = "showing";
4598
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
4599
+ break;
4600
+ }
4601
+ }
4602
+ this._dispatch("trackschange", {
4603
+ audioTracks: this._audioTracks,
4604
+ subtitleTracks: this.subtitleTracks
4605
+ });
4548
4606
  }
4549
4607
  /**
4550
4608
  * Disable the automatic `screen.orientation.lock()` that runs on
@@ -4723,7 +4781,6 @@ var PLAYER_STYLES = (
4723
4781
  position: relative;
4724
4782
  width: 100%;
4725
4783
  height: 100%;
4726
- cursor: pointer;
4727
4784
  -webkit-tap-highlight-color: transparent;
4728
4785
  user-select: none;
4729
4786
  }
@@ -5173,62 +5230,113 @@ var PLAYER_STYLES = (
5173
5230
 
5174
5231
  .avp-spacer { flex: 1; }
5175
5232
 
5176
- /* \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 */
5233
+ /* \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 */
5234
+
5235
+ /* Scrim \u2014 semi-transparent overlay behind the sheet, above the video.
5236
+ Tapping it dismisses the sheet. */
5237
+ .avp-settings-scrim {
5238
+ position: absolute;
5239
+ inset: 0;
5240
+ z-index: 9;
5241
+ background: rgba(0, 0, 0, 0.4);
5242
+ opacity: 0;
5243
+ pointer-events: none;
5244
+ transition: opacity 0.2s;
5245
+ }
5177
5246
 
5247
+ .avp-settings-scrim.open {
5248
+ opacity: 1;
5249
+ pointer-events: auto;
5250
+ }
5251
+
5252
+ /* Sheet container \u2014 slides up from the bottom. Height is content-driven
5253
+ up to a JS-measured max (set on open via style.maxHeight). */
5178
5254
  .avp-settings {
5179
5255
  position: absolute;
5180
- bottom: 52px;
5181
- right: 12px;
5182
- background: rgba(28, 28, 28, 0.95);
5183
- border-radius: 8px;
5184
- min-width: 220px;
5185
- /* Fit within the player: leave room for the controls bar (52px bottom)
5186
- and a small top margin (8px). On tall players this caps at 300px;
5187
- on short players it shrinks to whatever fits. */
5188
- max-height: min(300px, calc(100% - 52px - 8px));
5189
- overflow-y: auto;
5190
- display: none;
5256
+ bottom: 0;
5257
+ left: 0;
5258
+ right: 0;
5191
5259
  z-index: 10;
5192
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
5260
+ background: rgba(28, 28, 28, 0.97);
5261
+ border-radius: 12px 12px 0 0;
5262
+ overflow-y: auto;
5263
+ overscroll-behavior: contain;
5264
+ transform: translateY(100%);
5265
+ transition: transform 0.2s ease-out;
5266
+ max-height: 70%;
5267
+ padding-bottom: 52px;
5268
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
5193
5269
  }
5194
5270
 
5195
- .avp-settings.open { display: block; }
5271
+ .avp-settings.open {
5272
+ transform: translateY(0);
5273
+ }
5196
5274
 
5197
- .avp-settings-section {
5198
- padding: 8px 0;
5199
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
5275
+ /* Drag handle indicator at top of sheet. */
5276
+ .avp-settings-handle {
5277
+ width: 36px;
5278
+ height: 4px;
5279
+ border-radius: 2px;
5280
+ background: rgba(255, 255, 255, 0.3);
5281
+ margin: 8px auto 4px;
5200
5282
  }
5201
5283
 
5202
- .avp-settings-section:last-child { border-bottom: none; }
5284
+ /* \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 */
5203
5285
 
5204
- .avp-settings-label {
5205
- padding: 4px 16px;
5206
- font-size: 11px;
5207
- text-transform: uppercase;
5208
- letter-spacing: 0.5px;
5209
- opacity: 0.5;
5286
+ .avp-settings-section {
5287
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
5210
5288
  }
5211
5289
 
5212
- .avp-settings-item {
5290
+ .avp-settings-section:last-child { border-bottom: none; }
5291
+
5292
+ /* Section header \u2014 clickable row showing label + current value. */
5293
+ .avp-settings-header {
5294
+ position: relative;
5213
5295
  display: flex;
5214
5296
  align-items: center;
5215
- padding: 8px 16px;
5216
- font-size: 13px;
5297
+ justify-content: space-between;
5298
+ padding: 12px 16px;
5217
5299
  cursor: pointer;
5300
+ font-size: 14px;
5218
5301
  transition: background 0.1s;
5219
5302
  }
5220
5303
 
5221
- .avp-settings-item:hover { background: rgba(255, 255, 255, 0.1); }
5304
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
5305
+
5306
+ .avp-settings-header-label {
5307
+ display: flex;
5308
+ align-items: center;
5309
+ gap: 8px;
5310
+ font-weight: 500;
5311
+ }
5312
+
5313
+ .avp-settings-header-value {
5314
+ margin-left: auto;
5315
+ opacity: 0.6;
5316
+ font-size: 13px;
5317
+ text-align: right;
5318
+ }
5222
5319
 
5223
- .avp-settings-item.active {
5224
- color: #3ea6ff;
5320
+ /* Invisible native <select> layered over the value portion of the row.
5321
+ Covers from the value text to the right edge so tapping the value
5322
+ opens the OS picker. The label side remains inert. */
5323
+ .avp-settings-select {
5324
+ position: absolute;
5325
+ top: 0;
5326
+ right: 0;
5327
+ bottom: 0;
5328
+ width: 50%;
5329
+ opacity: 0;
5330
+ cursor: pointer;
5331
+ font-size: 16px;
5332
+ direction: rtl;
5225
5333
  }
5226
5334
 
5227
- .avp-settings-item.active::before {
5228
- content: "\\2713";
5229
- margin-right: 8px;
5230
- font-weight: bold;
5335
+ /* Toggle-style rows (Stats for Nerds) \u2014 no select, just clickable. */
5336
+ .avp-settings-toggle {
5337
+ cursor: pointer;
5231
5338
  }
5339
+ .avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
5232
5340
 
5233
5341
  /* \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 */
5234
5342
 
@@ -5308,7 +5416,7 @@ function formatTime(sec) {
5308
5416
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
5309
5417
  }
5310
5418
  var PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
5311
- var CONTROLS_HIDE_MS = 3e3;
5419
+ var DEFAULT_CONTROLS_HIDE_MS = 3e3;
5312
5420
  var FORWARDED_EVENTS = [
5313
5421
  "ready",
5314
5422
  "error",
@@ -5351,7 +5459,7 @@ var PROXY_ATTRIBUTES = [
5351
5459
  ];
5352
5460
  var PLAYER_ATTRIBUTES = ["show-fit"];
5353
5461
  var FIT_MODES = ["contain", "cover", "fill"];
5354
- var AvbridgePlayerElement = class extends HTMLElement {
5462
+ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5355
5463
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5356
5464
  // ── Internal DOM refs ──────────────────────────────────────────────────
5357
5465
  _video;
@@ -5367,6 +5475,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5367
5475
  _volumeInput;
5368
5476
  _settingsBtn;
5369
5477
  _settingsMenu;
5478
+ _settingsScrim;
5479
+ _customSections = [];
5370
5480
  _fullscreenBtn;
5371
5481
  // Strategy badge removed — visible in Stats for Nerds instead.
5372
5482
  // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
@@ -5377,6 +5487,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5377
5487
  _state = "idle";
5378
5488
  _controlsTimer = null;
5379
5489
  _settingsOpen = false;
5490
+ _activeAudioTrackId = null;
5491
+ _activeSubtitleTrackId = null;
5380
5492
  _userSeeking = false;
5381
5493
  _holdTimer = null;
5382
5494
  _holdSpeedActive = false;
@@ -5408,6 +5520,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5408
5520
  this._volumeInput = shadow.querySelector(".avp-volume-input");
5409
5521
  this._settingsBtn = shadow.querySelector(".avp-settings-btn");
5410
5522
  this._settingsMenu = shadow.querySelector(".avp-settings");
5523
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim");
5411
5524
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen");
5412
5525
  this._speedIndicator = shadow.querySelector(".avp-speed-indicator");
5413
5526
  this._statsEl = shadow.querySelector(".avp-stats");
@@ -5464,7 +5577,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5464
5577
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
5465
5578
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
5466
5579
  </div>
5467
- <div class="avp-settings" part="settings-menu"></div>
5580
+ <div class="avp-settings-scrim"></div>
5581
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
5468
5582
  </div>
5469
5583
  </div>`;
5470
5584
  }
@@ -5536,6 +5650,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5536
5650
  e.stopPropagation();
5537
5651
  this._toggleSettings();
5538
5652
  });
5653
+ on(this._settingsScrim, "click", () => this._closeSettings());
5539
5654
  on(this._fullscreenBtn, "click", (e) => {
5540
5655
  e.stopPropagation();
5541
5656
  this._toggleFullscreen();
@@ -5544,11 +5659,6 @@ var AvbridgePlayerElement = class extends HTMLElement {
5544
5659
  const container = this.shadowRoot.querySelector(".avp");
5545
5660
  on(container, "click", (e) => this._onContainerClick(e));
5546
5661
  on(container, "dblclick", (e) => this._onContainerDblClick(e));
5547
- on(container, "click", (e) => {
5548
- if (this._settingsOpen && !e.target.closest?.(".avp-settings-btn, .avp-settings")) {
5549
- this._closeSettings();
5550
- }
5551
- });
5552
5662
  on(document, "click", (e) => {
5553
5663
  if (this._settingsOpen && !this.contains(e.target)) {
5554
5664
  this._closeSettings();
@@ -5642,6 +5752,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5642
5752
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5643
5753
  return frac * (this._video.duration || 0);
5644
5754
  }
5755
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5756
+ * preview-only). On narrow bars precise positioning is hard, so
5757
+ * immediate video feedback is more useful than a time tooltip. */
5758
+ static SCRUB_WIDTH_THRESHOLD = 400;
5645
5759
  _onSeekPointerDown(e) {
5646
5760
  if (e.button !== 0 && e.pointerType === "mouse") return;
5647
5761
  e.preventDefault();
@@ -5649,15 +5763,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5649
5763
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5650
5764
  seekBar.setPointerCapture(e.pointerId);
5651
5765
  seekBar.setAttribute("data-seeking", "");
5766
+ const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5767
+ let lastScrubCommit = 0;
5652
5768
  const initial = this._timeFromSeekPointer(e.clientX);
5653
5769
  this._seekInput.value = String(initial);
5654
5770
  this._onSeekInput();
5655
5771
  this._updateSeekTooltip(e.clientX);
5772
+ if (scrubMode) this._onSeekCommit();
5656
5773
  const onMove = (ev) => {
5657
5774
  const t = this._timeFromSeekPointer(ev.clientX);
5658
5775
  this._seekInput.value = String(t);
5659
5776
  this._onSeekInput();
5660
5777
  this._updateSeekTooltip(ev.clientX);
5778
+ if (scrubMode) {
5779
+ const now = performance.now();
5780
+ if (now - lastScrubCommit > 250) {
5781
+ lastScrubCommit = now;
5782
+ this._onSeekCommit();
5783
+ this._userSeeking = true;
5784
+ }
5785
+ }
5661
5786
  };
5662
5787
  const onUp = (ev) => {
5663
5788
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -5726,83 +5851,123 @@ var AvbridgePlayerElement = class extends HTMLElement {
5726
5851
  _toggleSettings() {
5727
5852
  this._settingsOpen = !this._settingsOpen;
5728
5853
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
5729
- if (this._settingsOpen) this._showControls();
5854
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
5855
+ if (this._settingsOpen) {
5856
+ this._fitSettingsToPlayer();
5857
+ this._showControls();
5858
+ }
5859
+ }
5860
+ _fitSettingsToPlayer() {
5861
+ const container = this.shadowRoot?.querySelector(".avp");
5862
+ if (!container) return;
5863
+ const rect = container.getBoundingClientRect();
5864
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
5865
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
5730
5866
  }
5731
5867
  _closeSettings() {
5732
5868
  this._settingsOpen = false;
5733
5869
  this._settingsMenu.classList.remove("open");
5870
+ this._settingsScrim.classList.remove("open");
5734
5871
  }
5735
5872
  _buildSettingsMenu() {
5736
5873
  const sections = [];
5737
- if (this.hasAttribute("show-fit")) {
5738
- const currentFit = this._video.fit ?? "contain";
5739
- let fitItems = "";
5740
- for (const mode of FIT_MODES) {
5741
- const active = mode === currentFit;
5742
- const label = mode[0].toUpperCase() + mode.slice(1);
5743
- fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
5744
- }
5745
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
5746
- }
5874
+ 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>`;
5747
5875
  const currentRate = this._video.playbackRate ?? 1;
5748
- let speedItems = "";
5876
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
5877
+ let speedOpts = "";
5749
5878
  for (const spd of PLAYBACK_SPEEDS) {
5750
- const active = Math.abs(spd - currentRate) < 0.01;
5879
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
5751
5880
  const label = spd === 1 ? "Normal" : `${spd}x`;
5752
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
5881
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
5882
+ }
5883
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5884
+ const audios = this._video.audioTracks ?? [];
5885
+ if (audios.length > 1) {
5886
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
5887
+ const activeAudio = audios.find((t) => t.id === activeAudioId) ?? audios[0];
5888
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
5889
+ let audioOpts = "";
5890
+ for (const t of audios) {
5891
+ const sel = t.id === activeAudioId ? " selected" : "";
5892
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5893
+ }
5894
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
5753
5895
  }
5754
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
5755
5896
  const subs = this._video.subtitleTracks ?? [];
5756
5897
  if (subs.length > 0) {
5757
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
5898
+ const activeSubId = this._activeSubtitleTrackId;
5899
+ const activeSub = activeSubId != null ? subs.find((t) => t.id === activeSubId) : null;
5900
+ const subValue = activeSub ? activeSub.language ?? `Track ${activeSub.id}` : "Off";
5901
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
5758
5902
  for (const t of subs) {
5759
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5903
+ const sel = t.id === activeSubId ? " selected" : "";
5904
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
5760
5905
  }
5761
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
5906
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
5762
5907
  }
5763
- const audios = this._video.audioTracks ?? [];
5764
- if (audios.length > 1) {
5765
- let audioItems = "";
5766
- for (const t of audios) {
5767
- audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5908
+ if (this.hasAttribute("show-fit")) {
5909
+ const currentFit = this._video.fit ?? "contain";
5910
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
5911
+ let fitOpts = "";
5912
+ for (const mode of FIT_MODES) {
5913
+ const sel = mode === currentFit ? " selected" : "";
5914
+ const label = mode[0].toUpperCase() + mode.slice(1);
5915
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
5768
5916
  }
5769
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
5917
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
5770
5918
  }
5771
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
5772
- this._settingsMenu.innerHTML = sections.join("");
5773
- for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
5774
- item.addEventListener("click", (e) => {
5775
- e.stopPropagation();
5776
- const mode = item.dataset.fit;
5777
- this.setAttribute("fit", mode);
5778
- this._buildSettingsMenu();
5779
- });
5919
+ for (const cfg of this._customSections) {
5920
+ const activeItem = cfg.items.find((i) => i.active);
5921
+ let customOpts = "";
5922
+ for (const item of cfg.items) {
5923
+ const sel = item.active ? " selected" : "";
5924
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
5925
+ }
5926
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
5780
5927
  }
5781
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
5782
- item.addEventListener("click", (e) => {
5928
+ sections.push(
5929
+ `<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>`
5930
+ );
5931
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
5932
+ this._settingsMenu.innerHTML = "";
5933
+ if (handle) this._settingsMenu.appendChild(handle);
5934
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
5935
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
5936
+ for (const sel of this._settingsMenu.querySelectorAll(".avp-settings-select")) {
5937
+ sel.addEventListener("change", (e) => {
5783
5938
  e.stopPropagation();
5784
- this._video.playbackRate = Number(item.dataset.speed);
5939
+ const action = sel.dataset.action;
5940
+ const val = sel.value;
5941
+ switch (action) {
5942
+ case "speed":
5943
+ this._video.playbackRate = Number(val);
5944
+ break;
5945
+ case "audio":
5946
+ this._activeAudioTrackId = Number(val);
5947
+ void this._video.setAudioTrack(Number(val));
5948
+ break;
5949
+ case "subtitle": {
5950
+ const subId = Number(val);
5951
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
5952
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
5953
+ break;
5954
+ }
5955
+ case "fit":
5956
+ this.setAttribute("fit", val);
5957
+ break;
5958
+ case "custom": {
5959
+ const cfgId = sel.dataset.customId;
5960
+ const cfg = this._customSections.find((s) => s.id === cfgId);
5961
+ cfg?.onSelect(val);
5962
+ break;
5963
+ }
5964
+ }
5785
5965
  this._buildSettingsMenu();
5786
5966
  });
5787
5967
  }
5788
- for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
5789
- item.addEventListener("click", (e) => {
5790
- e.stopPropagation();
5791
- const id = Number(item.dataset.subtitle);
5792
- void this._video.setSubtitleTrack(id >= 0 ? id : null);
5793
- this._closeSettings();
5794
- });
5795
- }
5796
- for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
5797
- item.addEventListener("click", (e) => {
5798
- e.stopPropagation();
5799
- void this._video.setAudioTrack(Number(item.dataset.audio));
5800
- this._closeSettings();
5801
- });
5802
- }
5803
- const statsItem = this._settingsMenu.querySelector("[data-stats]");
5804
- if (statsItem) {
5805
- statsItem.addEventListener("click", (e) => {
5968
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
5969
+ if (statsRow) {
5970
+ statsRow.addEventListener("click", (e) => {
5806
5971
  e.stopPropagation();
5807
5972
  this._toggleStats();
5808
5973
  this._closeSettings();
@@ -5881,16 +6046,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
5881
6046
  _showControls() {
5882
6047
  this.showControls();
5883
6048
  }
5884
- _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
6049
+ _scheduleHide(durationMs) {
6050
+ const ms = durationMs ?? this._getControlsTimeout();
5885
6051
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
5886
6052
  if (this._state !== "playing" && this._state !== "buffering") return;
5887
6053
  if (this._settingsOpen) return;
6054
+ if (ms <= 0) return;
5888
6055
  this._controlsTimer = setTimeout(() => {
5889
6056
  if (this._state === "playing") {
5890
6057
  this.setAttribute("data-controls-hidden", "");
5891
6058
  this._toolbarTop.setAttribute("data-visible", "false");
5892
6059
  }
5893
- }, durationMs);
6060
+ }, ms);
6061
+ }
6062
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
6063
+ * Unset = default 3000ms. */
6064
+ _getControlsTimeout() {
6065
+ const attr = this.getAttribute("controls-timeout");
6066
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
6067
+ const n = Number(attr);
6068
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
5894
6069
  }
5895
6070
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
5896
6071
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -5902,6 +6077,9 @@ var AvbridgePlayerElement = class extends HTMLElement {
5902
6077
  // it's treated as a double-click and the single-click action is cancelled.
5903
6078
  /** Track whether the last interaction was touch so click handler can skip. */
5904
6079
  _lastPointerTypeWasTouch = false;
6080
+ /** True for ~50ms after a touch double-tap was handled, so the
6081
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
6082
+ _touchDoubleTapConsumed = false;
5905
6083
  /** True if the event's composed path passes through consumer-slotted
5906
6084
  * content (toolbar or content-overlay). Slotted content lives in the
5907
6085
  * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
@@ -5915,6 +6093,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5915
6093
  _onContainerClick(e) {
5916
6094
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5917
6095
  if (this._isSlottedContentEvent(e)) return;
6096
+ if (this._settingsOpen) {
6097
+ this._closeSettings();
6098
+ return;
6099
+ }
5918
6100
  if (this._lastPointerTypeWasTouch) {
5919
6101
  this._lastPointerTypeWasTouch = false;
5920
6102
  return;
@@ -5931,6 +6113,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5931
6113
  _onContainerDblClick(e) {
5932
6114
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5933
6115
  if (this._isSlottedContentEvent(e)) return;
6116
+ if (this._touchDoubleTapConsumed) return;
5934
6117
  if (this._tapTimer) {
5935
6118
  clearTimeout(this._tapTimer);
5936
6119
  this._tapTimer = null;
@@ -5953,6 +6136,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5953
6136
  this._lastPointerTypeWasTouch = true;
5954
6137
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5955
6138
  if (this._isSlottedContentEvent(e)) return;
6139
+ if (this._settingsOpen) {
6140
+ this._closeSettings();
6141
+ return;
6142
+ }
5956
6143
  const now = Date.now();
5957
6144
  if (now - this._lastTapTime < 300) {
5958
6145
  if (this._tapTimer) {
@@ -5968,6 +6155,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5968
6155
  } else {
5969
6156
  this._toggleFullscreen();
5970
6157
  }
6158
+ this._touchDoubleTapConsumed = true;
6159
+ setTimeout(() => {
6160
+ this._touchDoubleTapConsumed = false;
6161
+ }, 100);
5971
6162
  this._lastTapTime = 0;
5972
6163
  return;
5973
6164
  }
@@ -6001,6 +6192,13 @@ var AvbridgePlayerElement = class extends HTMLElement {
6001
6192
  this._video.currentTime = Math.max(0, this._video.currentTime + delta);
6002
6193
  }
6003
6194
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
6195
+ /** Duration of one frame in seconds, derived from diagnostics fps or
6196
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
6197
+ _frameDuration() {
6198
+ const diag = this._video.getDiagnostics();
6199
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
6200
+ return 1 / fps;
6201
+ }
6004
6202
  _onKeydown(e) {
6005
6203
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
6006
6204
  switch (e.key) {
@@ -6045,6 +6243,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
6045
6243
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
6046
6244
  this._buildSettingsMenu();
6047
6245
  break;
6246
+ case ",":
6247
+ e.preventDefault();
6248
+ if (!this._video.paused) this._video.pause();
6249
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
6250
+ break;
6251
+ case ".":
6252
+ e.preventDefault();
6253
+ if (!this._video.paused) this._video.pause();
6254
+ this._video.currentTime = Math.min(
6255
+ this._video.duration || 0,
6256
+ this._video.currentTime + this._frameDuration()
6257
+ );
6258
+ break;
6048
6259
  case "Escape":
6049
6260
  if (this._settingsOpen) {
6050
6261
  e.preventDefault();
@@ -6202,6 +6413,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
6202
6413
  async setAudioTrack(id) {
6203
6414
  return this._video.setAudioTrack(id);
6204
6415
  }
6416
+ addSettingsSection(config) {
6417
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
6418
+ this._customSections.push(config);
6419
+ this._buildSettingsMenu();
6420
+ }
6421
+ removeSettingsSection(id) {
6422
+ this._customSections = this._customSections.filter((s) => s.id !== id);
6423
+ this._buildSettingsMenu();
6424
+ }
6205
6425
  async setSubtitleTrack(id) {
6206
6426
  return this._video.setSubtitleTrack(id);
6207
6427
  }