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
@@ -0,0 +1,4 @@
1
+ export { SubtitleOverlay, SubtitleResourceBag, attachSubtitleTracks, discoverSidecars, srtToVtt } from './chunk-EDDWAN2L.js';
2
+ import './chunk-LUFA47FP.js';
3
+ //# sourceMappingURL=subtitles-5H24MEBJ.js.map
4
+ //# sourceMappingURL=subtitles-5H24MEBJ.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-4T74JRGT.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-5H24MEBJ.js"}
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ var chunkWRKO6Q42_cjs = require('./chunk-WRKO6Q42.cjs');
4
+ require('./chunk-QDJLQR53.cjs');
5
+
6
+
7
+
8
+ Object.defineProperty(exports, "SubtitleOverlay", {
9
+ enumerable: true,
10
+ get: function () { return chunkWRKO6Q42_cjs.SubtitleOverlay; }
11
+ });
12
+ Object.defineProperty(exports, "SubtitleResourceBag", {
13
+ enumerable: true,
14
+ get: function () { return chunkWRKO6Q42_cjs.SubtitleResourceBag; }
15
+ });
16
+ Object.defineProperty(exports, "attachSubtitleTracks", {
17
+ enumerable: true,
18
+ get: function () { return chunkWRKO6Q42_cjs.attachSubtitleTracks; }
19
+ });
20
+ Object.defineProperty(exports, "discoverSidecars", {
21
+ enumerable: true,
22
+ get: function () { return chunkWRKO6Q42_cjs.discoverSidecars; }
23
+ });
24
+ Object.defineProperty(exports, "srtToVtt", {
25
+ enumerable: true,
26
+ get: function () { return chunkWRKO6Q42_cjs.srtToVtt; }
27
+ });
28
+ //# sourceMappingURL=subtitles-HMVGWTU2.cjs.map
29
+ //# sourceMappingURL=subtitles-HMVGWTU2.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-QUH4LPI4.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-HMVGWTU2.cjs"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.9.0",
3
+ "version": "2.11.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",
@@ -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
 
@@ -39,7 +39,7 @@ function formatTime(sec: number): string {
39
39
  }
40
40
 
41
41
  const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;
42
- const CONTROLS_HIDE_MS = 3000;
42
+ const DEFAULT_CONTROLS_HIDE_MS = 3000;
43
43
 
44
44
  type PlayerState = "idle" | "loading" | "playing" | "paused" | "buffering" | "ended" | "error";
45
45
 
@@ -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.
@@ -99,6 +101,8 @@ export class AvbridgePlayerElement extends HTMLElement {
99
101
  private _state: PlayerState = "idle";
100
102
  private _controlsTimer: ReturnType<typeof setTimeout> | null = null;
101
103
  private _settingsOpen = false;
104
+ private _activeAudioTrackId: number | null = null;
105
+ private _activeSubtitleTrackId: number | null = null;
102
106
  private _userSeeking = false;
103
107
  private _holdTimer: ReturnType<typeof setTimeout> | null = null;
104
108
  private _holdSpeedActive = false;
@@ -133,6 +137,7 @@ export class AvbridgePlayerElement extends HTMLElement {
133
137
  this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement;
134
138
  this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement;
135
139
  this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement;
140
+ this._settingsScrim = shadow.querySelector(".avp-settings-scrim") as HTMLDivElement;
136
141
  this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement;
137
142
  // Badge removed from controls bar — strategy visible in Stats for Nerds.
138
143
  // Spinner is rendered in shadow DOM, driven by CSS :host([data-state]).
@@ -199,7 +204,8 @@ export class AvbridgePlayerElement extends HTMLElement {
199
204
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
200
205
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
201
206
  </div>
202
- <div class="avp-settings" part="settings-menu"></div>
207
+ <div class="avp-settings-scrim"></div>
208
+ <div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
203
209
  </div>
204
210
  </div>`;
205
211
  }
@@ -285,6 +291,7 @@ export class AvbridgePlayerElement extends HTMLElement {
285
291
 
286
292
  // Settings
287
293
  on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
294
+ on(this._settingsScrim, "click", () => this._closeSettings());
288
295
 
289
296
  // Fullscreen
290
297
  on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
@@ -298,14 +305,9 @@ export class AvbridgePlayerElement extends HTMLElement {
298
305
  on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
299
306
  on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
300
307
 
301
- // Dismiss settings menu on click outside (inside or outside the player)
302
- on(container, "click", (e) => {
303
- if (this._settingsOpen &&
304
- !(e.target as HTMLElement).closest?.(".avp-settings-btn, .avp-settings")) {
305
- this._closeSettings();
306
- }
307
- });
308
- // Also dismiss if user clicks outside the player element entirely
308
+ // Dismiss settings sheet when clicking outside it. The scrim handles
309
+ // most of this (its own click handler calls _closeSettings), but we
310
+ // also catch clicks outside the player element entirely.
309
311
  on(document, "click", (e) => {
310
312
  if (this._settingsOpen && !this.contains(e.target as Node)) {
311
313
  this._closeSettings();
@@ -435,6 +437,11 @@ export class AvbridgePlayerElement extends HTMLElement {
435
437
  return frac * (this._video.duration || 0);
436
438
  }
437
439
 
440
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
441
+ * preview-only). On narrow bars precise positioning is hard, so
442
+ * immediate video feedback is more useful than a time tooltip. */
443
+ private static readonly SCRUB_WIDTH_THRESHOLD = 400;
444
+
438
445
  private _onSeekPointerDown(e: PointerEvent): void {
439
446
  // Ignore synthetic clicks originating from the input's own handling
440
447
  if (e.button !== 0 && e.pointerType === "mouse") return;
@@ -444,16 +451,32 @@ export class AvbridgePlayerElement extends HTMLElement {
444
451
  seekBar.setPointerCapture(e.pointerId);
445
452
  seekBar.setAttribute("data-seeking", "");
446
453
 
454
+ // Decide scrub mode based on physical width.
455
+ const scrubMode = seekBar.getBoundingClientRect().width < AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
456
+ let lastScrubCommit = 0;
457
+
447
458
  const initial = this._timeFromSeekPointer(e.clientX);
448
459
  this._seekInput.value = String(initial);
449
460
  this._onSeekInput();
450
461
  this._updateSeekTooltip(e.clientX);
462
+ if (scrubMode) this._onSeekCommit();
451
463
 
452
464
  const onMove = (ev: PointerEvent) => {
453
465
  const t = this._timeFromSeekPointer(ev.clientX);
454
466
  this._seekInput.value = String(t);
455
467
  this._onSeekInput();
456
468
  this._updateSeekTooltip(ev.clientX);
469
+ // In scrub mode, commit seeks throttled to ~4 Hz so we don't
470
+ // overwhelm the seek pipeline (especially on canvas strategies
471
+ // where each seek restarts the decoder pump).
472
+ if (scrubMode) {
473
+ const now = performance.now();
474
+ if (now - lastScrubCommit > 250) {
475
+ lastScrubCommit = now;
476
+ this._onSeekCommit();
477
+ this._userSeeking = true; // keep seeking flag live
478
+ }
479
+ }
457
480
  };
458
481
  const onUp = (ev: PointerEvent) => {
459
482
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -535,100 +558,163 @@ export class AvbridgePlayerElement extends HTMLElement {
535
558
  private _toggleSettings(): void {
536
559
  this._settingsOpen = !this._settingsOpen;
537
560
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
538
- if (this._settingsOpen) this._showControls();
561
+ this._settingsScrim.classList.toggle("open", this._settingsOpen);
562
+ if (this._settingsOpen) {
563
+ this._fitSettingsToPlayer();
564
+ this._showControls();
565
+ }
566
+ }
567
+
568
+ private _fitSettingsToPlayer(): void {
569
+ const container = this.shadowRoot?.querySelector(".avp") as HTMLElement | null;
570
+ if (!container) return;
571
+ const rect = container.getBoundingClientRect();
572
+ // Bottom sheet can use up to 70% of the player height, leaving room
573
+ // to see the video behind the scrim. Floor at 120px so it's always
574
+ // usable.
575
+ const maxH = Math.max(120, Math.floor(rect.height * 0.7));
576
+ this._settingsMenu.style.maxHeight = `${maxH}px`;
539
577
  }
540
578
 
541
579
  private _closeSettings(): void {
542
580
  this._settingsOpen = false;
543
581
  this._settingsMenu.classList.remove("open");
582
+ this._settingsScrim.classList.remove("open");
544
583
  }
545
584
 
546
585
  private _buildSettingsMenu(): void {
547
586
  const sections: string[] = [];
548
587
 
549
- // Fit mode opt-in via the `show-fit` attribute. Off by default so
550
- // chromeless consumers don't get a surprise entry they have to theme
551
- // around.
552
- if (this.hasAttribute("show-fit")) {
553
- const currentFit = (this._video.fit ?? "contain") as FitMode;
554
- let fitItems = "";
555
- for (const mode of FIT_MODES) {
556
- const active = mode === currentFit;
557
- const label = mode[0].toUpperCase() + mode.slice(1);
558
- fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
559
- }
560
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
561
- }
562
-
563
- // Playback speed
588
+ // Helper: a row with an invisible native <select> layered on top of
589
+ // a styled label+value. Tapping opens the OS picker (escapes the
590
+ // shadow DOM — intentional for small players). The visible label
591
+ // updates on change.
592
+ const selectRow = (label: string, currentValue: string, options: string, selectAttrs: string): string =>
593
+ `<div class="avp-settings-section">` +
594
+ `<div class="avp-settings-header">` +
595
+ `<span class="avp-settings-header-label">${label}</span>` +
596
+ `<span class="avp-settings-header-value">${currentValue}</span>` +
597
+ `<select class="avp-settings-select" ${selectAttrs}>${options}</select>` +
598
+ `</div>` +
599
+ `</div>`;
600
+
601
+ // Speed
564
602
  const currentRate = this._video.playbackRate ?? 1;
565
- let speedItems = "";
603
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
604
+ let speedOpts = "";
566
605
  for (const spd of PLAYBACK_SPEEDS) {
567
- const active = Math.abs(spd - currentRate) < 0.01;
606
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
568
607
  const label = spd === 1 ? "Normal" : `${spd}x`;
569
- speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
608
+ speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
609
+ }
610
+ sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
611
+
612
+ // Audio tracks
613
+ const audios = this._video.audioTracks ?? [];
614
+ if (audios.length > 1) {
615
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
616
+ const activeAudio = audios.find((t: { id: number }) => t.id === activeAudioId) ?? audios[0];
617
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
618
+ let audioOpts = "";
619
+ for (const t of audios) {
620
+ const sel = t.id === activeAudioId ? " selected" : "";
621
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
622
+ }
623
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
570
624
  }
571
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
572
625
 
573
626
  // Subtitle tracks
574
627
  const subs = this._video.subtitleTracks ?? [];
575
628
  if (subs.length > 0) {
576
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
629
+ const activeSubId = this._activeSubtitleTrackId;
630
+ const activeSub = activeSubId != null ? subs.find((t: { id: number }) => t.id === activeSubId) : null;
631
+ const subValue = activeSub ? (activeSub.language ?? `Track ${activeSub.id}`) : "Off";
632
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
577
633
  for (const t of subs) {
578
- subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
634
+ const sel = t.id === activeSubId ? " selected" : "";
635
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
579
636
  }
580
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
637
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
581
638
  }
582
639
 
583
- // Audio tracks
584
- const audios = this._video.audioTracks ?? [];
585
- if (audios.length > 1) {
586
- let audioItems = "";
587
- for (const t of audios) {
588
- audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
640
+ // Fit mode — opt-in via the `show-fit` attribute
641
+ if (this.hasAttribute("show-fit")) {
642
+ const currentFit = (this._video.fit ?? "contain") as FitMode;
643
+ const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
644
+ let fitOpts = "";
645
+ for (const mode of FIT_MODES) {
646
+ const sel = mode === currentFit ? " selected" : "";
647
+ const label = mode[0].toUpperCase() + mode.slice(1);
648
+ fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
589
649
  }
590
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
650
+ sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
591
651
  }
592
652
 
593
- // Stats for nerds
594
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
595
-
596
- this._settingsMenu.innerHTML = sections.join("");
597
-
598
- // Bind click handlers
599
- for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
600
- item.addEventListener("click", (e) => {
601
- e.stopPropagation();
602
- const mode = (item as HTMLElement).dataset.fit as FitMode;
603
- this.setAttribute("fit", mode);
604
- this._buildSettingsMenu();
605
- });
653
+ // Consumer-added sections
654
+ for (const cfg of this._customSections) {
655
+ const activeItem = cfg.items.find((i) => i.active);
656
+ let customOpts = "";
657
+ for (const item of cfg.items) {
658
+ const sel = item.active ? " selected" : "";
659
+ customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
660
+ }
661
+ sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
606
662
  }
607
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
608
- item.addEventListener("click", (e) => {
663
+
664
+ // Stats for nerds — toggle row (no select)
665
+ sections.push(
666
+ `<div class="avp-settings-section">` +
667
+ `<div class="avp-settings-header avp-settings-toggle" data-stats>` +
668
+ `<span class="avp-settings-header-label">Stats for Nerds</span>` +
669
+ `</div>` +
670
+ `</div>`,
671
+ );
672
+
673
+ // Rebuild sheet content (preserve the handle).
674
+ const handle = this._settingsMenu.querySelector(".avp-settings-handle");
675
+ this._settingsMenu.innerHTML = "";
676
+ if (handle) this._settingsMenu.appendChild(handle);
677
+ else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
678
+ this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
679
+
680
+ // ── <select> change handlers ──
681
+ for (const sel of this._settingsMenu.querySelectorAll<HTMLSelectElement>(".avp-settings-select")) {
682
+ sel.addEventListener("change", (e) => {
609
683
  e.stopPropagation();
610
- this._video.playbackRate = Number((item as HTMLElement).dataset.speed);
684
+ const action = sel.dataset.action;
685
+ const val = sel.value;
686
+ switch (action) {
687
+ case "speed":
688
+ this._video.playbackRate = Number(val);
689
+ break;
690
+ case "audio":
691
+ this._activeAudioTrackId = Number(val);
692
+ void this._video.setAudioTrack(Number(val));
693
+ break;
694
+ case "subtitle": {
695
+ const subId = Number(val);
696
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
697
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
698
+ break;
699
+ }
700
+ case "fit":
701
+ this.setAttribute("fit", val);
702
+ break;
703
+ case "custom": {
704
+ const cfgId = sel.dataset.customId!;
705
+ const cfg = this._customSections.find((s) => s.id === cfgId);
706
+ cfg?.onSelect(val);
707
+ break;
708
+ }
709
+ }
611
710
  this._buildSettingsMenu();
612
711
  });
613
712
  }
614
- for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
615
- item.addEventListener("click", (e) => {
616
- e.stopPropagation();
617
- const id = Number((item as HTMLElement).dataset.subtitle);
618
- void this._video.setSubtitleTrack(id >= 0 ? id : null);
619
- this._closeSettings();
620
- });
621
- }
622
- for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
623
- item.addEventListener("click", (e) => {
624
- e.stopPropagation();
625
- void this._video.setAudioTrack(Number((item as HTMLElement).dataset.audio));
626
- this._closeSettings();
627
- });
628
- }
629
- const statsItem = this._settingsMenu.querySelector("[data-stats]");
630
- if (statsItem) {
631
- statsItem.addEventListener("click", (e) => {
713
+
714
+ // Stats toggle (no select — just a clickable row)
715
+ const statsRow = this._settingsMenu.querySelector("[data-stats]");
716
+ if (statsRow) {
717
+ statsRow.addEventListener("click", (e) => {
632
718
  e.stopPropagation();
633
719
  this._toggleStats();
634
720
  this._closeSettings();
@@ -711,16 +797,28 @@ export class AvbridgePlayerElement extends HTMLElement {
711
797
  this.showControls();
712
798
  }
713
799
 
714
- private _scheduleHide(durationMs: number = CONTROLS_HIDE_MS): void {
800
+ private _scheduleHide(durationMs?: number): void {
801
+ const ms = durationMs ?? this._getControlsTimeout();
715
802
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
716
803
  if (this._state !== "playing" && this._state !== "buffering") return;
717
804
  if (this._settingsOpen) return;
805
+ // A timeout of 0 or negative means "never hide" (controls always visible).
806
+ if (ms <= 0) return;
718
807
  this._controlsTimer = setTimeout(() => {
719
808
  if (this._state === "playing") {
720
809
  this.setAttribute("data-controls-hidden", "");
721
810
  this._toolbarTop.setAttribute("data-visible", "false");
722
811
  }
723
- }, durationMs);
812
+ }, ms);
813
+ }
814
+
815
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
816
+ * Unset = default 3000ms. */
817
+ private _getControlsTimeout(): number {
818
+ const attr = this.getAttribute("controls-timeout");
819
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
820
+ const n = Number(attr);
821
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
724
822
  }
725
823
 
726
824
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
@@ -735,6 +833,9 @@ export class AvbridgePlayerElement extends HTMLElement {
735
833
 
736
834
  /** Track whether the last interaction was touch so click handler can skip. */
737
835
  private _lastPointerTypeWasTouch = false;
836
+ /** True for ~50ms after a touch double-tap was handled, so the
837
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
838
+ private _touchDoubleTapConsumed = false;
738
839
 
739
840
  /** True if the event's composed path passes through consumer-slotted
740
841
  * content (toolbar or content-overlay). Slotted content lives in the
@@ -750,10 +851,17 @@ export class AvbridgePlayerElement extends HTMLElement {
750
851
  }
751
852
 
752
853
  private _onContainerClick(e: MouseEvent): void {
753
- // Ignore clicks on controls
854
+ // Ignore clicks on controls and slotted content
754
855
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
755
856
  if (this._isSlottedContentEvent(e)) return;
756
857
 
858
+ // If the bottom sheet is open, any click outside it dismisses
859
+ // instead of toggling play/pause.
860
+ if (this._settingsOpen) {
861
+ this._closeSettings();
862
+ return;
863
+ }
864
+
757
865
  // Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
758
866
  // The browser fires a synthetic click after touchend — skip it.
759
867
  if (this._lastPointerTypeWasTouch) {
@@ -772,6 +880,11 @@ export class AvbridgePlayerElement extends HTMLElement {
772
880
  private _onContainerDblClick(e: MouseEvent): void {
773
881
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
774
882
  if (this._isSlottedContentEvent(e)) return;
883
+ // On touch devices, the browser synthesizes a dblclick after two
884
+ // rapid taps. But we already handled the double-tap in _onPointerUp
885
+ // (which does ff/rw on sides, fullscreen in center). Skip the
886
+ // synthetic dblclick so both don't fire.
887
+ if (this._touchDoubleTapConsumed) return;
775
888
  // Cancel the pending single-click play/pause
776
889
  if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
777
890
  this._toggleFullscreen();
@@ -799,6 +912,12 @@ export class AvbridgePlayerElement extends HTMLElement {
799
912
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
800
913
  if (this._isSlottedContentEvent(e)) return;
801
914
 
915
+ // If the bottom sheet is open, dismiss it on any touch outside.
916
+ if (this._settingsOpen) {
917
+ this._closeSettings();
918
+ return;
919
+ }
920
+
802
921
  // Double-tap detection
803
922
  const now = Date.now();
804
923
  if (now - this._lastTapTime < 300) {
@@ -813,6 +932,10 @@ export class AvbridgePlayerElement extends HTMLElement {
813
932
  } else {
814
933
  this._toggleFullscreen();
815
934
  }
935
+ // Prevent the synthetic dblclick (fired ~50ms later by the
936
+ // browser) from also toggling fullscreen.
937
+ this._touchDoubleTapConsumed = true;
938
+ setTimeout(() => { this._touchDoubleTapConsumed = false; }, 100);
816
939
  this._lastTapTime = 0;
817
940
  return;
818
941
  }
@@ -854,6 +977,14 @@ export class AvbridgePlayerElement extends HTMLElement {
854
977
 
855
978
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
856
979
 
980
+ /** Duration of one frame in seconds, derived from diagnostics fps or
981
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
982
+ private _frameDuration(): number {
983
+ const diag = this._video.getDiagnostics() as { fps?: number } | null;
984
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
985
+ return 1 / fps;
986
+ }
987
+
857
988
  private _onKeydown(e: KeyboardEvent): void {
858
989
  // Don't intercept if the user is typing in an input
859
990
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
@@ -900,6 +1031,21 @@ export class AvbridgePlayerElement extends HTMLElement {
900
1031
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
901
1032
  this._buildSettingsMenu();
902
1033
  break;
1034
+ case ",":
1035
+ // Frame-back (YouTube-style: , while paused steps back one frame)
1036
+ e.preventDefault();
1037
+ if (!this._video.paused) this._video.pause();
1038
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
1039
+ break;
1040
+ case ".":
1041
+ // Frame-forward (YouTube-style: . while paused steps forward one frame)
1042
+ e.preventDefault();
1043
+ if (!this._video.paused) this._video.pause();
1044
+ this._video.currentTime = Math.min(
1045
+ this._video.duration || 0,
1046
+ this._video.currentTime + this._frameDuration(),
1047
+ );
1048
+ break;
903
1049
  case "Escape":
904
1050
  if (this._settingsOpen) {
905
1051
  e.preventDefault();
@@ -991,6 +1137,17 @@ export class AvbridgePlayerElement extends HTMLElement {
991
1137
  return this._video.destroy();
992
1138
  }
993
1139
  async setAudioTrack(id: number): Promise<void> { return this._video.setAudioTrack(id); }
1140
+
1141
+ addSettingsSection(config: SettingsSectionConfig): void {
1142
+ this._customSections = this._customSections.filter((s) => s.id !== config.id);
1143
+ this._customSections.push(config);
1144
+ this._buildSettingsMenu();
1145
+ }
1146
+
1147
+ removeSettingsSection(id: string): void {
1148
+ this._customSections = this._customSections.filter((s) => s.id !== id);
1149
+ this._buildSettingsMenu();
1150
+ }
994
1151
  async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
995
1152
  getDiagnostics(): unknown { return this._video.getDiagnostics(); }
996
1153
  canPlayType(mime: string): string { return this._video.canPlayType(mime); }