avbridge 2.8.4 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +74 -1
  3. package/dist/{avi-F6WZJK5T.cjs → avi-2ILLBNPQ.cjs} +8 -2
  4. package/dist/avi-2ILLBNPQ.cjs.map +1 -0
  5. package/dist/{avi-W6L3BTWU.cjs → avi-B5CQYB7L.cjs} +8 -2
  6. package/dist/avi-B5CQYB7L.cjs.map +1 -0
  7. package/dist/{avi-2JPBSHGA.js → avi-JXU4GQL2.js} +8 -2
  8. package/dist/avi-JXU4GQL2.js.map +1 -0
  9. package/dist/{avi-NJXAXUXK.js → avi-RWWPN2PR.js} +8 -2
  10. package/dist/avi-RWWPN2PR.js.map +1 -0
  11. package/dist/{chunk-X2K3GIWE.js → chunk-2NSOOMXW.js} +14 -3
  12. package/dist/chunk-2NSOOMXW.js.map +1 -0
  13. package/dist/{chunk-KBWQRGHS.js → chunk-3GKM5DFM.js} +119 -8
  14. package/dist/chunk-3GKM5DFM.js.map +1 -0
  15. package/dist/{chunk-ZCUXHW55.cjs → chunk-BYGZN4Z5.cjs} +5 -5
  16. package/dist/{chunk-ZCUXHW55.cjs.map → chunk-BYGZN4Z5.cjs.map} +1 -1
  17. package/dist/{chunk-SMH6IOP2.js → chunk-CL6UEUQF.js} +4 -4
  18. package/dist/{chunk-SMH6IOP2.js.map → chunk-CL6UEUQF.js.map} +1 -1
  19. package/dist/{chunk-SR3MPV4D.js → chunk-GYIJU44C.js} +5 -5
  20. package/dist/{chunk-SR3MPV4D.js.map → chunk-GYIJU44C.js.map} +1 -1
  21. package/dist/{chunk-CPZ7PXAM.cjs → chunk-L7A3ECI2.cjs} +14 -2
  22. package/dist/chunk-L7A3ECI2.cjs.map +1 -0
  23. package/dist/{chunk-YX4AGLNF.cjs → chunk-NQULEIA3.cjs} +129 -18
  24. package/dist/chunk-NQULEIA3.cjs.map +1 -0
  25. package/dist/{chunk-Q2VUO52Z.cjs → chunk-OTFS7DC4.cjs} +12 -12
  26. package/dist/{chunk-Q2VUO52Z.cjs.map → chunk-OTFS7DC4.cjs.map} +1 -1
  27. package/dist/element-browser.js +144 -10
  28. package/dist/element-browser.js.map +1 -1
  29. package/dist/element.cjs +16 -10
  30. package/dist/element.cjs.map +1 -1
  31. package/dist/element.d.cts +11 -6
  32. package/dist/element.d.ts +11 -6
  33. package/dist/element.js +15 -9
  34. package/dist/element.js.map +1 -1
  35. package/dist/index.cjs +20 -20
  36. package/dist/index.d.cts +2 -2
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.js +8 -8
  39. package/dist/libav-demux-3N5Y3VQA.cjs +31 -0
  40. package/dist/{libav-demux-H2GS46GH.cjs.map → libav-demux-3N5Y3VQA.cjs.map} +1 -1
  41. package/dist/libav-demux-JXD4OTLM.js +6 -0
  42. package/dist/{libav-demux-OWZ4T2YW.js.map → libav-demux-JXD4OTLM.js.map} +1 -1
  43. package/dist/{player-BptSJPfn.d.cts → player-DDdNVFDv.d.cts} +24 -2
  44. package/dist/{player-BptSJPfn.d.ts → player-DDdNVFDv.d.ts} +24 -2
  45. package/dist/player.cjs +413 -117
  46. package/dist/player.cjs.map +1 -1
  47. package/dist/player.d.cts +44 -11
  48. package/dist/player.d.ts +44 -11
  49. package/dist/player.js +413 -117
  50. package/dist/player.js.map +1 -1
  51. package/dist/{remux-WBYIZBBX.js → remux-56V7LDAD.js} +5 -5
  52. package/dist/{remux-WBYIZBBX.js.map → remux-56V7LDAD.js.map} +1 -1
  53. package/dist/{remux-OBSMIENG.cjs → remux-KUS5GIL6.cjs} +10 -10
  54. package/dist/{remux-OBSMIENG.cjs.map → remux-KUS5GIL6.cjs.map} +1 -1
  55. package/package.json +1 -1
  56. package/src/classify/rules.ts +2 -0
  57. package/src/element/avbridge-player.ts +172 -86
  58. package/src/element/avbridge-video.ts +22 -6
  59. package/src/element/player-styles.ts +149 -34
  60. package/src/index.ts +1 -0
  61. package/src/probe/avi.ts +2 -0
  62. package/src/strategies/fallback/audio-output.ts +29 -4
  63. package/src/strategies/fallback/decoder.ts +30 -0
  64. package/src/strategies/fallback/index.ts +42 -0
  65. package/src/strategies/hybrid/decoder.ts +35 -0
  66. package/src/strategies/hybrid/index.ts +26 -0
  67. package/src/strategies/remux/index.ts +8 -0
  68. package/src/types.ts +31 -0
  69. package/src/util/libav-demux.ts +26 -0
  70. package/dist/avi-2JPBSHGA.js.map +0 -1
  71. package/dist/avi-F6WZJK5T.cjs.map +0 -1
  72. package/dist/avi-NJXAXUXK.js.map +0 -1
  73. package/dist/avi-W6L3BTWU.cjs.map +0 -1
  74. package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
  75. package/dist/chunk-KBWQRGHS.js.map +0 -1
  76. package/dist/chunk-X2K3GIWE.js.map +0 -1
  77. package/dist/chunk-YX4AGLNF.cjs.map +0 -1
  78. package/dist/libav-demux-H2GS46GH.cjs +0 -27
  79. package/dist/libav-demux-OWZ4T2YW.js +0 -6
@@ -1,10 +1,10 @@
1
- export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-SMH6IOP2.js';
2
- import './chunk-SR3MPV4D.js';
1
+ export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-CL6UEUQF.js';
2
+ import './chunk-GYIJU44C.js';
3
3
  import './chunk-CPJLFFCC.js';
4
4
  import './chunk-LUFA47FP.js';
5
- import './chunk-X2K3GIWE.js';
5
+ import './chunk-2NSOOMXW.js';
6
6
  import './chunk-DCSOQH2N.js';
7
7
  import './chunk-5DMTJVIU.js';
8
8
  import './chunk-5YAWWKA3.js';
9
- //# sourceMappingURL=remux-WBYIZBBX.js.map
10
- //# sourceMappingURL=remux-WBYIZBBX.js.map
9
+ //# sourceMappingURL=remux-56V7LDAD.js.map
10
+ //# sourceMappingURL=remux-56V7LDAD.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-WBYIZBBX.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-56V7LDAD.js"}
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- var chunkQ2VUO52Z_cjs = require('./chunk-Q2VUO52Z.cjs');
4
- require('./chunk-ZCUXHW55.cjs');
3
+ var chunkOTFS7DC4_cjs = require('./chunk-OTFS7DC4.cjs');
4
+ require('./chunk-BYGZN4Z5.cjs');
5
5
  require('./chunk-2IJ66NTD.cjs');
6
6
  require('./chunk-QDJLQR53.cjs');
7
- require('./chunk-CPZ7PXAM.cjs');
7
+ require('./chunk-L7A3ECI2.cjs');
8
8
  require('./chunk-Z33SBWL5.cjs');
9
9
  require('./chunk-G4APZMCP.cjs');
10
10
  require('./chunk-F3LQJKXK.cjs');
@@ -13,23 +13,23 @@ require('./chunk-F3LQJKXK.cjs');
13
13
 
14
14
  Object.defineProperty(exports, "createOutputFormat", {
15
15
  enumerable: true,
16
- get: function () { return chunkQ2VUO52Z_cjs.createOutputFormat; }
16
+ get: function () { return chunkOTFS7DC4_cjs.createOutputFormat; }
17
17
  });
18
18
  Object.defineProperty(exports, "generateFilename", {
19
19
  enumerable: true,
20
- get: function () { return chunkQ2VUO52Z_cjs.generateFilename; }
20
+ get: function () { return chunkOTFS7DC4_cjs.generateFilename; }
21
21
  });
22
22
  Object.defineProperty(exports, "mimeForFormat", {
23
23
  enumerable: true,
24
- get: function () { return chunkQ2VUO52Z_cjs.mimeForFormat; }
24
+ get: function () { return chunkOTFS7DC4_cjs.mimeForFormat; }
25
25
  });
26
26
  Object.defineProperty(exports, "remux", {
27
27
  enumerable: true,
28
- get: function () { return chunkQ2VUO52Z_cjs.remux; }
28
+ get: function () { return chunkOTFS7DC4_cjs.remux; }
29
29
  });
30
30
  Object.defineProperty(exports, "validateRemuxEligibility", {
31
31
  enumerable: true,
32
- get: function () { return chunkQ2VUO52Z_cjs.validateRemuxEligibility; }
32
+ get: function () { return chunkOTFS7DC4_cjs.validateRemuxEligibility; }
33
33
  });
34
- //# sourceMappingURL=remux-OBSMIENG.cjs.map
35
- //# sourceMappingURL=remux-OBSMIENG.cjs.map
34
+ //# sourceMappingURL=remux-KUS5GIL6.cjs.map
35
+ //# sourceMappingURL=remux-KUS5GIL6.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-OBSMIENG.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-KUS5GIL6.cjs"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.8.4",
3
+ "version": "2.10.0",
4
4
  "description": "Play and convert arbitrary video files in the browser. Native, remux, hybrid, fallback, and transcode — one API.",
5
5
  "license": "MIT",
6
6
  "author": "Keishi Hattori",
@@ -31,6 +31,8 @@ export const FALLBACK_VIDEO_CODECS = new Set<VideoCodec>([
31
31
  "wmv3", "vc1", "mpeg4",
32
32
  "rv10", "rv20", "rv30", "rv40",
33
33
  "mpeg2", "mpeg1", "theora",
34
+ "dv", "hq_hqa",
35
+ "rawvideo", "qtrle", "png", "vp6f",
34
36
  ]);
35
37
  export const FALLBACK_AUDIO_CODECS = new Set<AudioCodec>([
36
38
  "wmav2", "wmapro", "ac3", "eac3",
@@ -23,7 +23,7 @@ import {
23
23
  ICON_FULLSCREEN, ICON_FULLSCREEN_EXIT,
24
24
  ICON_REPLAY_10, ICON_FORWARD_10,
25
25
  } from "./player-icons.js";
26
- import type { AvbridgeVideoElementEventMap } from "../types.js";
26
+ import type { AvbridgeVideoElementEventMap, SettingsSectionConfig } from "../types.js";
27
27
 
28
28
  // ── Helpers ──────────────────────────────────────────────────────────────
29
29
 
@@ -87,6 +87,8 @@ export class AvbridgePlayerElement extends HTMLElement {
87
87
  private _volumeInput!: HTMLInputElement;
88
88
  private _settingsBtn!: HTMLButtonElement;
89
89
  private _settingsMenu!: HTMLDivElement;
90
+ private _settingsScrim!: HTMLDivElement;
91
+ private _customSections: import("../types.js").SettingsSectionConfig[] = [];
90
92
  private _fullscreenBtn!: HTMLButtonElement;
91
93
  // Strategy badge removed — visible in Stats for Nerds instead.
92
94
  // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
@@ -133,6 +135,7 @@ export class AvbridgePlayerElement extends HTMLElement {
133
135
  this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement;
134
136
  this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement;
135
137
  this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement;
138
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim") as HTMLDivElement;
136
139
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement;
137
140
  // Badge removed from controls bar — strategy visible in Stats for Nerds.
138
141
  // Spinner is rendered in shadow DOM, driven by CSS :host([data-state]).
@@ -167,6 +170,7 @@ export class AvbridgePlayerElement extends HTMLElement {
167
170
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
168
171
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
169
172
  </div>
173
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
170
174
  <div part="overlay" class="avp-overlay">
171
175
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
172
176
  <div class="avp-spinner"></div>
@@ -198,7 +202,8 @@ export class AvbridgePlayerElement extends HTMLElement {
198
202
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
199
203
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
200
204
  </div>
201
- <div class="avp-settings" part="settings-menu"></div>
205
+ <div class="avp-settings-scrim"></div>
206
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
202
207
  </div>
203
208
  </div>`;
204
209
  }
@@ -284,6 +289,7 @@ export class AvbridgePlayerElement extends HTMLElement {
284
289
 
285
290
  // Settings
286
291
  on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
292
+ on(this._settingsScrim, "click", () => this._closeSettings());
287
293
 
288
294
  // Fullscreen
289
295
  on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
@@ -297,14 +303,9 @@ export class AvbridgePlayerElement extends HTMLElement {
297
303
  on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
298
304
  on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
299
305
 
300
- // Dismiss settings menu on click outside (inside or outside the player)
301
- on(container, "click", (e) => {
302
- if (this._settingsOpen &&
303
- !(e.target as HTMLElement).closest?.(".avp-settings-btn, .avp-settings")) {
304
- this._closeSettings();
305
- }
306
- });
307
- // Also dismiss if user clicks outside the player element entirely
306
+ // Dismiss settings sheet when clicking outside it. The scrim handles
307
+ // most of this (its own click handler calls _closeSettings), but we
308
+ // also catch clicks outside the player element entirely.
308
309
  on(document, "click", (e) => {
309
310
  if (this._settingsOpen && !this.contains(e.target as Node)) {
310
311
  this._closeSettings();
@@ -441,21 +442,25 @@ export class AvbridgePlayerElement extends HTMLElement {
441
442
  this._userSeeking = true;
442
443
  const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
443
444
  seekBar.setPointerCapture(e.pointerId);
445
+ seekBar.setAttribute("data-seeking", "");
444
446
 
445
447
  const initial = this._timeFromSeekPointer(e.clientX);
446
448
  this._seekInput.value = String(initial);
447
449
  this._onSeekInput();
450
+ this._updateSeekTooltip(e.clientX);
448
451
 
449
452
  const onMove = (ev: PointerEvent) => {
450
453
  const t = this._timeFromSeekPointer(ev.clientX);
451
454
  this._seekInput.value = String(t);
452
455
  this._onSeekInput();
456
+ this._updateSeekTooltip(ev.clientX);
453
457
  };
454
458
  const onUp = (ev: PointerEvent) => {
455
459
  const t = this._timeFromSeekPointer(ev.clientX);
456
460
  this._seekInput.value = String(t);
457
461
  this._onSeekCommit();
458
- this._seekInput.focus(); // keep keyboard nav responsive
462
+ this._seekInput.focus();
463
+ seekBar.removeAttribute("data-seeking");
459
464
  seekBar.removeEventListener("pointermove", onMove);
460
465
  seekBar.removeEventListener("pointerup", onUp);
461
466
  seekBar.removeEventListener("pointercancel", onUp);
@@ -467,8 +472,12 @@ export class AvbridgePlayerElement extends HTMLElement {
467
472
  }
468
473
 
469
474
  private _onSeekHover(e: PointerEvent): void {
475
+ this._updateSeekTooltip(e.clientX);
476
+ }
477
+
478
+ private _updateSeekTooltip(clientX: number): void {
470
479
  const rect = this._seekInput.getBoundingClientRect();
471
- const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
480
+ const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
472
481
  const t = frac * (this._video.duration || 0);
473
482
  this._seekTooltip.textContent = formatTime(t);
474
483
  this._seekTooltip.style.left = `${frac * 100}%`;
@@ -526,100 +535,151 @@ export class AvbridgePlayerElement extends HTMLElement {
526
535
  private _toggleSettings(): void {
527
536
  this._settingsOpen = !this._settingsOpen;
528
537
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
529
- if (this._settingsOpen) this._showControls();
538
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
539
+ if (this._settingsOpen) {
540
+ this._fitSettingsToPlayer();
541
+ this._showControls();
542
+ }
543
+ }
544
+
545
+ private _fitSettingsToPlayer(): void {
546
+ const container = this.shadowRoot?.querySelector(".avp") as HTMLElement | null;
547
+ if (!container) return;
548
+ const rect = container.getBoundingClientRect();
549
+ // Bottom sheet can use up to 70% of the player height, leaving room
550
+ // to see the video behind the scrim. Floor at 120px so it's always
551
+ // usable.
552
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
553
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
530
554
  }
531
555
 
532
556
  private _closeSettings(): void {
533
557
  this._settingsOpen = false;
534
558
  this._settingsMenu.classList.remove("open");
559
+ this._settingsScrim.classList.remove("open");
535
560
  }
536
561
 
537
562
  private _buildSettingsMenu(): void {
538
563
  const sections: string[] = [];
539
564
 
540
- // Fit mode opt-in via the `show-fit` attribute. Off by default so
541
- // chromeless consumers don't get a surprise entry they have to theme
542
- // around.
543
- if (this.hasAttribute("show-fit")) {
544
- const currentFit = (this._video.fit ?? "contain") as FitMode;
545
- let fitItems = "";
546
- for (const mode of FIT_MODES) {
547
- const active = mode === currentFit;
548
- const label = mode[0].toUpperCase() + mode.slice(1);
549
- fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
550
- }
551
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
552
- }
553
-
554
- // Playback speed
565
+ // Helper: a row with an invisible native <select> layered on top of
566
+ // a styled label+value. Tapping opens the OS picker (escapes the
567
+ // shadow DOM — intentional for small players). The visible label
568
+ // updates on change.
569
+ const selectRow = (label: string, currentValue: string, options: string, selectAttrs: string): string =>
570
+ `<div class="avp-settings-section">` +
571
+ `<div class="avp-settings-header">` +
572
+ `<span class="avp-settings-header-label">${label}</span>` +
573
+ `<span class="avp-settings-header-value">${currentValue}</span>` +
574
+ `<select class="avp-settings-select" ${selectAttrs}>${options}</select>` +
575
+ `</div>` +
576
+ `</div>`;
577
+
578
+ // Speed
555
579
  const currentRate = this._video.playbackRate ?? 1;
556
- let speedItems = "";
580
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
581
+ let speedOpts = "";
557
582
  for (const spd of PLAYBACK_SPEEDS) {
558
- const active = Math.abs(spd - currentRate) < 0.01;
583
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
559
584
  const label = spd === 1 ? "Normal" : `${spd}x`;
560
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
585
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
586
+ }
587
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
588
+
589
+ // Audio tracks
590
+ const audios = this._video.audioTracks ?? [];
591
+ if (audios.length > 1) {
592
+ let audioOpts = "";
593
+ for (const t of audios) {
594
+ audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
595
+ }
596
+ sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
561
597
  }
562
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
563
598
 
564
599
  // Subtitle tracks
565
600
  const subs = this._video.subtitleTracks ?? [];
566
601
  if (subs.length > 0) {
567
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
602
+ let subOpts = `<option value="-1" selected>Off</option>`;
568
603
  for (const t of subs) {
569
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
604
+ subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
570
605
  }
571
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
606
+ sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
572
607
  }
573
608
 
574
- // Audio tracks
575
- const audios = this._video.audioTracks ?? [];
576
- if (audios.length > 1) {
577
- let audioItems = "";
578
- for (const t of audios) {
579
- audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
609
+ // Fit mode — opt-in via the `show-fit` attribute
610
+ if (this.hasAttribute("show-fit")) {
611
+ const currentFit = (this._video.fit ?? "contain") as FitMode;
612
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
613
+ let fitOpts = "";
614
+ for (const mode of FIT_MODES) {
615
+ const sel = mode === currentFit ? " selected" : "";
616
+ const label = mode[0].toUpperCase() + mode.slice(1);
617
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
580
618
  }
581
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
619
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
582
620
  }
583
621
 
584
- // Stats for nerds
585
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
586
-
587
- this._settingsMenu.innerHTML = sections.join("");
588
-
589
- // Bind click handlers
590
- for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
591
- item.addEventListener("click", (e) => {
592
- e.stopPropagation();
593
- const mode = (item as HTMLElement).dataset.fit as FitMode;
594
- this.setAttribute("fit", mode);
595
- this._buildSettingsMenu();
596
- });
622
+ // Consumer-added sections
623
+ for (const cfg of this._customSections) {
624
+ const activeItem = cfg.items.find((i) => i.active);
625
+ let customOpts = "";
626
+ for (const item of cfg.items) {
627
+ const sel = item.active ? " selected" : "";
628
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
629
+ }
630
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
597
631
  }
598
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
599
- item.addEventListener("click", (e) => {
632
+
633
+ // Stats for nerds — toggle row (no select)
634
+ sections.push(
635
+ `<div class="avp-settings-section">` +
636
+ `<div class="avp-settings-header avp-settings-toggle" data-stats>` +
637
+ `<span class="avp-settings-header-label">Stats for Nerds</span>` +
638
+ `</div>` +
639
+ `</div>`,
640
+ );
641
+
642
+ // Rebuild sheet content (preserve the handle).
643
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
644
+ this._settingsMenu.innerHTML = "";
645
+ if (handle) this._settingsMenu.appendChild(handle);
646
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
647
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
648
+
649
+ // ── <select> change handlers ──
650
+ for (const sel of this._settingsMenu.querySelectorAll<HTMLSelectElement>(".avp-settings-select")) {
651
+ sel.addEventListener("change", (e) => {
600
652
  e.stopPropagation();
601
- this._video.playbackRate = Number((item as HTMLElement).dataset.speed);
653
+ const action = sel.dataset.action;
654
+ const val = sel.value;
655
+ switch (action) {
656
+ case "speed":
657
+ this._video.playbackRate = Number(val);
658
+ break;
659
+ case "audio":
660
+ void this._video.setAudioTrack(Number(val));
661
+ break;
662
+ case "subtitle":
663
+ void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
664
+ break;
665
+ case "fit":
666
+ this.setAttribute("fit", val);
667
+ break;
668
+ case "custom": {
669
+ const cfgId = sel.dataset.customId!;
670
+ const cfg = this._customSections.find((s) => s.id === cfgId);
671
+ cfg?.onSelect(val);
672
+ break;
673
+ }
674
+ }
602
675
  this._buildSettingsMenu();
603
676
  });
604
677
  }
605
- for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
606
- item.addEventListener("click", (e) => {
607
- e.stopPropagation();
608
- const id = Number((item as HTMLElement).dataset.subtitle);
609
- void this._video.setSubtitleTrack(id >= 0 ? id : null);
610
- this._closeSettings();
611
- });
612
- }
613
- for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
614
- item.addEventListener("click", (e) => {
615
- e.stopPropagation();
616
- void this._video.setAudioTrack(Number((item as HTMLElement).dataset.audio));
617
- this._closeSettings();
618
- });
619
- }
620
- const statsItem = this._settingsMenu.querySelector("[data-stats]");
621
- if (statsItem) {
622
- statsItem.addEventListener("click", (e) => {
678
+
679
+ // Stats toggle (no select — just a clickable row)
680
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
681
+ if (statsRow) {
682
+ statsRow.addEventListener("click", (e) => {
623
683
  e.stopPropagation();
624
684
  this._toggleStats();
625
685
  this._closeSettings();
@@ -727,21 +787,30 @@ export class AvbridgePlayerElement extends HTMLElement {
727
787
  /** Track whether the last interaction was touch so click handler can skip. */
728
788
  private _lastPointerTypeWasTouch = false;
729
789
 
730
- /** True if the event's composed path passes through consumer-slotted toolbar
731
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
732
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
733
- * does. */
734
- private _isToolbarEvent(e: Event): boolean {
790
+ /** True if the event's composed path passes through consumer-slotted
791
+ * content (toolbar or content-overlay). Slotted content lives in the
792
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
793
+ * find the shadow-DOM wrapper — `composedPath()` does. */
794
+ private _isSlottedContentEvent(e: Event): boolean {
735
795
  for (const node of e.composedPath()) {
736
- if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
796
+ if (node instanceof HTMLElement &&
797
+ (node.classList.contains("avp-toolbar-top") ||
798
+ node.classList.contains("avp-content-overlay"))) return true;
737
799
  }
738
800
  return false;
739
801
  }
740
802
 
741
803
  private _onContainerClick(e: MouseEvent): void {
742
- // Ignore clicks on controls
804
+ // Ignore clicks on controls and slotted content
743
805
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
744
- if (this._isToolbarEvent(e)) return;
806
+ if (this._isSlottedContentEvent(e)) return;
807
+
808
+ // If the bottom sheet is open, any click outside it dismisses
809
+ // instead of toggling play/pause.
810
+ if (this._settingsOpen) {
811
+ this._closeSettings();
812
+ return;
813
+ }
745
814
 
746
815
  // Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
747
816
  // The browser fires a synthetic click after touchend — skip it.
@@ -760,7 +829,7 @@ export class AvbridgePlayerElement extends HTMLElement {
760
829
 
761
830
  private _onContainerDblClick(e: MouseEvent): void {
762
831
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
763
- if (this._isToolbarEvent(e)) return;
832
+ if (this._isSlottedContentEvent(e)) return;
764
833
  // Cancel the pending single-click play/pause
765
834
  if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
766
835
  this._toggleFullscreen();
@@ -786,7 +855,13 @@ export class AvbridgePlayerElement extends HTMLElement {
786
855
 
787
856
  // Ignore touches on controls — buttons have their own handlers
788
857
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
789
- if (this._isToolbarEvent(e)) return;
858
+ if (this._isSlottedContentEvent(e)) return;
859
+
860
+ // If the bottom sheet is open, dismiss it on any touch outside.
861
+ if (this._settingsOpen) {
862
+ this._closeSettings();
863
+ return;
864
+ }
790
865
 
791
866
  // Double-tap detection
792
867
  const now = Date.now();
@@ -980,6 +1055,17 @@ export class AvbridgePlayerElement extends HTMLElement {
980
1055
  return this._video.destroy();
981
1056
  }
982
1057
  async setAudioTrack(id: number): Promise<void> { return this._video.setAudioTrack(id); }
1058
+
1059
+ addSettingsSection(config: SettingsSectionConfig): void {
1060
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
1061
+ this._customSections.push(config);
1062
+ this._buildSettingsMenu();
1063
+ }
1064
+
1065
+ removeSettingsSection(id: string): void {
1066
+ this._customSections = this._customSections.filter((s) => s.id !== id);
1067
+ this._buildSettingsMenu();
1068
+ }
983
1069
  async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
984
1070
  getDiagnostics(): unknown { return this._video.getDiagnostics(); }
985
1071
  canPlayType(mime: string): string { return this._video.canPlayType(mime); }
@@ -598,10 +598,21 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
598
598
  }
599
599
 
600
600
  get muted(): boolean {
601
- return this.hasAttribute("muted");
601
+ // Read through to the inner <video>'s IDL property — on canvas
602
+ // strategies the property is patched via Object.defineProperty to
603
+ // mirror AudioOutput state, and consumers need the truthful value.
604
+ return this._videoEl.muted;
602
605
  }
603
606
 
604
607
  set muted(value: boolean) {
608
+ // Drive the IDL property (fires volumechange per HTML spec) rather
609
+ // than toggling the attribute (which on most browsers is parse-time
610
+ // only and does NOT fire volumechange when toggled runtime). On
611
+ // canvas strategies, the property is patched via Object.defineProperty
612
+ // which also dispatches volumechange; one code path, both worlds.
613
+ this._videoEl.muted = value;
614
+ // Keep the attribute in sync so CSS selectors like [muted] and
615
+ // re-queries via getAttribute reflect current state.
605
616
  if (value) this.setAttribute("muted", "");
606
617
  else this.removeAttribute("muted");
607
618
  }
@@ -683,11 +694,16 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
683
694
 
684
695
  /**
685
696
  * Buffered time ranges for the active source. Mirrors the standard
686
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
687
- * this reflects the underlying SourceBuffer / progressive download state.
688
- * For the hybrid and fallback (canvas-rendered) strategies it currently
689
- * returns an empty TimeRanges; a future release will synthesize a coarse
690
- * range from the decoder's read position.
697
+ * `<video>.buffered` `TimeRanges` API.
698
+ *
699
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
700
+ * (reflects the browser's SourceBuffer / progressive-download state).
701
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
702
+ * from the demuxer's read progress — "how far libav has ever pumped
703
+ * packets through." Monotonic; does not shrink on seek. This is an
704
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
705
+ * are consumed in flight, so we can't report per-range availability
706
+ * the way MSE does. Enough for a seek-bar buffered indicator.
691
707
  */
692
708
  get buffered(): TimeRanges {
693
709
  return this._videoEl.buffered;