avbridge 2.9.0 → 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.
package/dist/player.d.cts CHANGED
@@ -287,6 +287,28 @@ interface AvbridgeVideoElementEventMap {
287
287
  fit: "contain" | "cover" | "fill";
288
288
  }>;
289
289
  }
290
+ /**
291
+ * Configuration for a custom settings section added to `<avbridge-player>`
292
+ * via {@link addSettingsSection}. Sections render in the bottom-sheet
293
+ * settings panel alongside built-in sections (Speed, Audio, Subtitles,
294
+ * Fit, Stats for Nerds). The player owns rendering — consumers describe
295
+ * data; avbridge renders it in a consistent visual style.
296
+ */
297
+ interface SettingsSectionConfig {
298
+ /** Unique id for this section. Used to update/remove later. */
299
+ id: string;
300
+ /** Display label (e.g. "Quality", "Translate"). */
301
+ label: string;
302
+ /** Items to show when the section is expanded. */
303
+ items: Array<{
304
+ id: string;
305
+ label: string;
306
+ /** Mark the currently-selected item. */
307
+ active?: boolean;
308
+ }>;
309
+ /** Called when the user picks an item. */
310
+ onSelect(itemId: string): void;
311
+ }
290
312
 
291
313
  /**
292
314
  * `<avbridge-player>` — YouTube-style controls element.
@@ -314,6 +336,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
314
336
  private _volumeInput;
315
337
  private _settingsBtn;
316
338
  private _settingsMenu;
339
+ private _settingsScrim;
340
+ private _customSections;
317
341
  private _fullscreenBtn;
318
342
  private _speedIndicator;
319
343
  private _rippleLeft;
@@ -353,6 +377,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
353
377
  private _toggleMute;
354
378
  private _updateVolume;
355
379
  private _toggleSettings;
380
+ private _fitSettingsToPlayer;
356
381
  private _closeSettings;
357
382
  private _buildSettingsMenu;
358
383
  private _toggleStats;
@@ -437,6 +462,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
437
462
  load(): Promise<void>;
438
463
  destroy(): Promise<void>;
439
464
  setAudioTrack(id: number): Promise<void>;
465
+ addSettingsSection(config: SettingsSectionConfig): void;
466
+ removeSettingsSection(id: string): void;
440
467
  setSubtitleTrack(id: number | null): Promise<void>;
441
468
  getDiagnostics(): unknown;
442
469
  canPlayType(mime: string): string;
package/dist/player.d.ts CHANGED
@@ -287,6 +287,28 @@ interface AvbridgeVideoElementEventMap {
287
287
  fit: "contain" | "cover" | "fill";
288
288
  }>;
289
289
  }
290
+ /**
291
+ * Configuration for a custom settings section added to `<avbridge-player>`
292
+ * via {@link addSettingsSection}. Sections render in the bottom-sheet
293
+ * settings panel alongside built-in sections (Speed, Audio, Subtitles,
294
+ * Fit, Stats for Nerds). The player owns rendering — consumers describe
295
+ * data; avbridge renders it in a consistent visual style.
296
+ */
297
+ interface SettingsSectionConfig {
298
+ /** Unique id for this section. Used to update/remove later. */
299
+ id: string;
300
+ /** Display label (e.g. "Quality", "Translate"). */
301
+ label: string;
302
+ /** Items to show when the section is expanded. */
303
+ items: Array<{
304
+ id: string;
305
+ label: string;
306
+ /** Mark the currently-selected item. */
307
+ active?: boolean;
308
+ }>;
309
+ /** Called when the user picks an item. */
310
+ onSelect(itemId: string): void;
311
+ }
290
312
 
291
313
  /**
292
314
  * `<avbridge-player>` — YouTube-style controls element.
@@ -314,6 +336,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
314
336
  private _volumeInput;
315
337
  private _settingsBtn;
316
338
  private _settingsMenu;
339
+ private _settingsScrim;
340
+ private _customSections;
317
341
  private _fullscreenBtn;
318
342
  private _speedIndicator;
319
343
  private _rippleLeft;
@@ -353,6 +377,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
353
377
  private _toggleMute;
354
378
  private _updateVolume;
355
379
  private _toggleSettings;
380
+ private _fitSettingsToPlayer;
356
381
  private _closeSettings;
357
382
  private _buildSettingsMenu;
358
383
  private _toggleStats;
@@ -437,6 +462,8 @@ declare class AvbridgePlayerElement extends HTMLElement {
437
462
  load(): Promise<void>;
438
463
  destroy(): Promise<void>;
439
464
  setAudioTrack(id: number): Promise<void>;
465
+ addSettingsSection(config: SettingsSectionConfig): void;
466
+ removeSettingsSection(id: string): void;
440
467
  setSubtitleTrack(id: number | null): Promise<void>;
441
468
  getDiagnostics(): unknown;
442
469
  canPlayType(mime: string): string;
package/dist/player.js CHANGED
@@ -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;
@@ -2531,6 +2550,14 @@ async function createHybridSession(ctx, target, transport) {
2531
2550
  get: () => ctx.duration ?? NaN
2532
2551
  });
2533
2552
  }
2553
+ Object.defineProperty(target, "playbackRate", {
2554
+ configurable: true,
2555
+ get: () => audio.getPlaybackRate(),
2556
+ set: (v) => {
2557
+ audio.setPlaybackRate(v);
2558
+ target.dispatchEvent(new Event("ratechange"));
2559
+ }
2560
+ });
2534
2561
  Object.defineProperty(target, "readyState", {
2535
2562
  configurable: true,
2536
2563
  get: () => {
@@ -2641,6 +2668,7 @@ async function createHybridSession(ctx, target, transport) {
2641
2668
  delete target.muted;
2642
2669
  delete target.readyState;
2643
2670
  delete target.seekable;
2671
+ delete target.playbackRate;
2644
2672
  } catch {
2645
2673
  }
2646
2674
  },
@@ -3176,6 +3204,14 @@ async function createFallbackSession(ctx, target, transport) {
3176
3204
  get: () => ctx.duration ?? NaN
3177
3205
  });
3178
3206
  }
3207
+ Object.defineProperty(target, "playbackRate", {
3208
+ configurable: true,
3209
+ get: () => audio.getPlaybackRate(),
3210
+ set: (v) => {
3211
+ audio.setPlaybackRate(v);
3212
+ target.dispatchEvent(new Event("ratechange"));
3213
+ }
3214
+ });
3179
3215
  Object.defineProperty(target, "readyState", {
3180
3216
  configurable: true,
3181
3217
  get: () => {
@@ -3307,6 +3343,7 @@ async function createFallbackSession(ctx, target, transport) {
3307
3343
  delete target.muted;
3308
3344
  delete target.readyState;
3309
3345
  delete target.seekable;
3346
+ delete target.playbackRate;
3310
3347
  } catch {
3311
3348
  }
3312
3349
  },
@@ -4723,7 +4760,6 @@ var PLAYER_STYLES = (
4723
4760
  position: relative;
4724
4761
  width: 100%;
4725
4762
  height: 100%;
4726
- cursor: pointer;
4727
4763
  -webkit-tap-highlight-color: transparent;
4728
4764
  user-select: none;
4729
4765
  }
@@ -5173,63 +5209,114 @@ var PLAYER_STYLES = (
5173
5209
 
5174
5210
  .avp-spacer { flex: 1; }
5175
5211
 
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 */
5212
+ /* \u2500\u2500 Settings bottom sheet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5177
5213
 
5214
+ /* Scrim \u2014 semi-transparent overlay behind the sheet, above the video.
5215
+ Tapping it dismisses the sheet. */
5216
+ .avp-settings-scrim {
5217
+ position: absolute;
5218
+ inset: 0;
5219
+ z-index: 9;
5220
+ background: rgba(0, 0, 0, 0.4);
5221
+ opacity: 0;
5222
+ pointer-events: none;
5223
+ transition: opacity 0.2s;
5224
+ }
5225
+
5226
+ .avp-settings-scrim.open {
5227
+ opacity: 1;
5228
+ pointer-events: auto;
5229
+ }
5230
+
5231
+ /* Sheet container \u2014 slides up from the bottom. Height is content-driven
5232
+ up to a JS-measured max (set on open via style.maxHeight). */
5178
5233
  .avp-settings {
5179
5234
  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;
5235
+ bottom: 0;
5236
+ left: 0;
5237
+ right: 0;
5191
5238
  z-index: 10;
5192
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
5239
+ background: rgba(28, 28, 28, 0.97);
5240
+ border-radius: 12px 12px 0 0;
5241
+ overflow-y: auto;
5242
+ overscroll-behavior: contain;
5243
+ transform: translateY(100%);
5244
+ transition: transform 0.2s ease-out;
5245
+ max-height: 70%;
5246
+ padding-bottom: 52px;
5247
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
5193
5248
  }
5194
5249
 
5195
- .avp-settings.open { display: block; }
5250
+ .avp-settings.open {
5251
+ transform: translateY(0);
5252
+ }
5196
5253
 
5197
- .avp-settings-section {
5198
- padding: 8px 0;
5199
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
5254
+ /* Drag handle indicator at top of sheet. */
5255
+ .avp-settings-handle {
5256
+ width: 36px;
5257
+ height: 4px;
5258
+ border-radius: 2px;
5259
+ background: rgba(255, 255, 255, 0.3);
5260
+ margin: 8px auto 4px;
5200
5261
  }
5201
5262
 
5202
- .avp-settings-section:last-child { border-bottom: none; }
5263
+ /* \u2500\u2500 Accordion sections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5203
5264
 
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;
5265
+ .avp-settings-section {
5266
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
5210
5267
  }
5211
5268
 
5212
- .avp-settings-item {
5269
+ .avp-settings-section:last-child { border-bottom: none; }
5270
+
5271
+ /* Section header \u2014 clickable row showing label + current value. */
5272
+ .avp-settings-header {
5273
+ position: relative;
5213
5274
  display: flex;
5214
5275
  align-items: center;
5215
- padding: 8px 16px;
5216
- font-size: 13px;
5276
+ justify-content: space-between;
5277
+ padding: 12px 16px;
5217
5278
  cursor: pointer;
5279
+ font-size: 14px;
5218
5280
  transition: background 0.1s;
5219
5281
  }
5220
5282
 
5221
- .avp-settings-item:hover { background: rgba(255, 255, 255, 0.1); }
5283
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
5222
5284
 
5223
- .avp-settings-item.active {
5224
- color: #3ea6ff;
5285
+ .avp-settings-header-label {
5286
+ display: flex;
5287
+ align-items: center;
5288
+ gap: 8px;
5289
+ font-weight: 500;
5225
5290
  }
5226
5291
 
5227
- .avp-settings-item.active::before {
5228
- content: "\\2713";
5229
- margin-right: 8px;
5230
- font-weight: bold;
5292
+ .avp-settings-header-value {
5293
+ margin-left: auto;
5294
+ opacity: 0.6;
5295
+ font-size: 13px;
5296
+ text-align: right;
5231
5297
  }
5232
5298
 
5299
+ /* Invisible native <select> layered over the value portion of the row.
5300
+ Covers from the value text to the right edge so tapping the value
5301
+ opens the OS picker. The label side remains inert. */
5302
+ .avp-settings-select {
5303
+ position: absolute;
5304
+ top: 0;
5305
+ right: 0;
5306
+ bottom: 0;
5307
+ width: 50%;
5308
+ opacity: 0;
5309
+ cursor: pointer;
5310
+ font-size: 16px;
5311
+ direction: rtl;
5312
+ }
5313
+
5314
+ /* Toggle-style rows (Stats for Nerds) \u2014 no select, just clickable. */
5315
+ .avp-settings-toggle {
5316
+ cursor: pointer;
5317
+ }
5318
+ .avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
5319
+
5233
5320
  /* \u2500\u2500 Stats for nerds \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5234
5321
 
5235
5322
  .avp-stats {
@@ -5367,6 +5454,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5367
5454
  _volumeInput;
5368
5455
  _settingsBtn;
5369
5456
  _settingsMenu;
5457
+ _settingsScrim;
5458
+ _customSections = [];
5370
5459
  _fullscreenBtn;
5371
5460
  // Strategy badge removed — visible in Stats for Nerds instead.
5372
5461
  // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
@@ -5408,6 +5497,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5408
5497
  this._volumeInput = shadow.querySelector(".avp-volume-input");
5409
5498
  this._settingsBtn = shadow.querySelector(".avp-settings-btn");
5410
5499
  this._settingsMenu = shadow.querySelector(".avp-settings");
5500
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim");
5411
5501
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen");
5412
5502
  this._speedIndicator = shadow.querySelector(".avp-speed-indicator");
5413
5503
  this._statsEl = shadow.querySelector(".avp-stats");
@@ -5464,7 +5554,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
5464
5554
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
5465
5555
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
5466
5556
  </div>
5467
- <div class="avp-settings" part="settings-menu"></div>
5557
+ <div class="avp-settings-scrim"></div>
5558
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
5468
5559
  </div>
5469
5560
  </div>`;
5470
5561
  }
@@ -5536,6 +5627,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5536
5627
  e.stopPropagation();
5537
5628
  this._toggleSettings();
5538
5629
  });
5630
+ on(this._settingsScrim, "click", () => this._closeSettings());
5539
5631
  on(this._fullscreenBtn, "click", (e) => {
5540
5632
  e.stopPropagation();
5541
5633
  this._toggleFullscreen();
@@ -5544,11 +5636,6 @@ var AvbridgePlayerElement = class extends HTMLElement {
5544
5636
  const container = this.shadowRoot.querySelector(".avp");
5545
5637
  on(container, "click", (e) => this._onContainerClick(e));
5546
5638
  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
5639
  on(document, "click", (e) => {
5553
5640
  if (this._settingsOpen && !this.contains(e.target)) {
5554
5641
  this._closeSettings();
@@ -5726,83 +5813,111 @@ var AvbridgePlayerElement = class extends HTMLElement {
5726
5813
  _toggleSettings() {
5727
5814
  this._settingsOpen = !this._settingsOpen;
5728
5815
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
5729
- if (this._settingsOpen) this._showControls();
5816
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
5817
+ if (this._settingsOpen) {
5818
+ this._fitSettingsToPlayer();
5819
+ this._showControls();
5820
+ }
5821
+ }
5822
+ _fitSettingsToPlayer() {
5823
+ const container = this.shadowRoot?.querySelector(".avp");
5824
+ if (!container) return;
5825
+ const rect = container.getBoundingClientRect();
5826
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
5827
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
5730
5828
  }
5731
5829
  _closeSettings() {
5732
5830
  this._settingsOpen = false;
5733
5831
  this._settingsMenu.classList.remove("open");
5832
+ this._settingsScrim.classList.remove("open");
5734
5833
  }
5735
5834
  _buildSettingsMenu() {
5736
5835
  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
- }
5836
+ const selectRow = (label, currentValue, options, selectAttrs) => `<div class="avp-settings-section"><div class="avp-settings-header"><span class="avp-settings-header-label">${label}</span><span class="avp-settings-header-value">${currentValue}</span><select class="avp-settings-select" ${selectAttrs}>${options}</select></div></div>`;
5747
5837
  const currentRate = this._video.playbackRate ?? 1;
5748
- let speedItems = "";
5838
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
5839
+ let speedOpts = "";
5749
5840
  for (const spd of PLAYBACK_SPEEDS) {
5750
- const active = Math.abs(spd - currentRate) < 0.01;
5841
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
5751
5842
  const label = spd === 1 ? "Normal" : `${spd}x`;
5752
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
5843
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
5844
+ }
5845
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
5846
+ const audios = this._video.audioTracks ?? [];
5847
+ if (audios.length > 1) {
5848
+ let audioOpts = "";
5849
+ for (const t of audios) {
5850
+ audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5851
+ }
5852
+ sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
5753
5853
  }
5754
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
5755
5854
  const subs = this._video.subtitleTracks ?? [];
5756
5855
  if (subs.length > 0) {
5757
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
5856
+ let subOpts = `<option value="-1" selected>Off</option>`;
5758
5857
  for (const t of subs) {
5759
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
5858
+ subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
5760
5859
  }
5761
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
5860
+ sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
5762
5861
  }
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>`;
5862
+ if (this.hasAttribute("show-fit")) {
5863
+ const currentFit = this._video.fit ?? "contain";
5864
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
5865
+ let fitOpts = "";
5866
+ for (const mode of FIT_MODES) {
5867
+ const sel = mode === currentFit ? " selected" : "";
5868
+ const label = mode[0].toUpperCase() + mode.slice(1);
5869
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
5768
5870
  }
5769
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
5871
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
5770
5872
  }
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
- });
5873
+ for (const cfg of this._customSections) {
5874
+ const activeItem = cfg.items.find((i) => i.active);
5875
+ let customOpts = "";
5876
+ for (const item of cfg.items) {
5877
+ const sel = item.active ? " selected" : "";
5878
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
5879
+ }
5880
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
5780
5881
  }
5781
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
5782
- item.addEventListener("click", (e) => {
5882
+ sections.push(
5883
+ `<div class="avp-settings-section"><div class="avp-settings-header avp-settings-toggle" data-stats><span class="avp-settings-header-label">Stats for Nerds</span></div></div>`
5884
+ );
5885
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
5886
+ this._settingsMenu.innerHTML = "";
5887
+ if (handle) this._settingsMenu.appendChild(handle);
5888
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
5889
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
5890
+ for (const sel of this._settingsMenu.querySelectorAll(".avp-settings-select")) {
5891
+ sel.addEventListener("change", (e) => {
5783
5892
  e.stopPropagation();
5784
- this._video.playbackRate = Number(item.dataset.speed);
5893
+ const action = sel.dataset.action;
5894
+ const val = sel.value;
5895
+ switch (action) {
5896
+ case "speed":
5897
+ this._video.playbackRate = Number(val);
5898
+ break;
5899
+ case "audio":
5900
+ void this._video.setAudioTrack(Number(val));
5901
+ break;
5902
+ case "subtitle":
5903
+ void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
5904
+ break;
5905
+ case "fit":
5906
+ this.setAttribute("fit", val);
5907
+ break;
5908
+ case "custom": {
5909
+ const cfgId = sel.dataset.customId;
5910
+ const cfg = this._customSections.find((s) => s.id === cfgId);
5911
+ cfg?.onSelect(val);
5912
+ break;
5913
+ }
5914
+ }
5785
5915
  this._buildSettingsMenu();
5786
5916
  });
5787
5917
  }
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) => {
5918
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
5919
+ if (statsRow) {
5920
+ statsRow.addEventListener("click", (e) => {
5806
5921
  e.stopPropagation();
5807
5922
  this._toggleStats();
5808
5923
  this._closeSettings();
@@ -5915,6 +6030,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5915
6030
  _onContainerClick(e) {
5916
6031
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5917
6032
  if (this._isSlottedContentEvent(e)) return;
6033
+ if (this._settingsOpen) {
6034
+ this._closeSettings();
6035
+ return;
6036
+ }
5918
6037
  if (this._lastPointerTypeWasTouch) {
5919
6038
  this._lastPointerTypeWasTouch = false;
5920
6039
  return;
@@ -5953,6 +6072,10 @@ var AvbridgePlayerElement = class extends HTMLElement {
5953
6072
  this._lastPointerTypeWasTouch = true;
5954
6073
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5955
6074
  if (this._isSlottedContentEvent(e)) return;
6075
+ if (this._settingsOpen) {
6076
+ this._closeSettings();
6077
+ return;
6078
+ }
5956
6079
  const now = Date.now();
5957
6080
  if (now - this._lastTapTime < 300) {
5958
6081
  if (this._tapTimer) {
@@ -6202,6 +6325,15 @@ var AvbridgePlayerElement = class extends HTMLElement {
6202
6325
  async setAudioTrack(id) {
6203
6326
  return this._video.setAudioTrack(id);
6204
6327
  }
6328
+ addSettingsSection(config) {
6329
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
6330
+ this._customSections.push(config);
6331
+ this._buildSettingsMenu();
6332
+ }
6333
+ removeSettingsSection(id) {
6334
+ this._customSections = this._customSections.filter((s) => s.id !== id);
6335
+ this._buildSettingsMenu();
6336
+ }
6205
6337
  async setSubtitleTrack(id) {
6206
6338
  return this._video.setSubtitleTrack(id);
6207
6339
  }