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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.9.0",
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",
@@ -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]).
@@ -199,7 +202,8 @@ export class AvbridgePlayerElement extends HTMLElement {
199
202
  <button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
200
203
  <button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
201
204
  </div>
202
- <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>
203
207
  </div>
204
208
  </div>`;
205
209
  }
@@ -285,6 +289,7 @@ export class AvbridgePlayerElement extends HTMLElement {
285
289
 
286
290
  // Settings
287
291
  on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
292
+ on(this._settingsScrim, "click", () => this._closeSettings());
288
293
 
289
294
  // Fullscreen
290
295
  on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
@@ -298,14 +303,9 @@ export class AvbridgePlayerElement extends HTMLElement {
298
303
  on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
299
304
  on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
300
305
 
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
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.
309
309
  on(document, "click", (e) => {
310
310
  if (this._settingsOpen && !this.contains(e.target as Node)) {
311
311
  this._closeSettings();
@@ -535,100 +535,151 @@ export class AvbridgePlayerElement extends HTMLElement {
535
535
  private _toggleSettings(): void {
536
536
  this._settingsOpen = !this._settingsOpen;
537
537
  this._settingsMenu.classList.toggle("open", this._settingsOpen);
538
- 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`;
539
554
  }
540
555
 
541
556
  private _closeSettings(): void {
542
557
  this._settingsOpen = false;
543
558
  this._settingsMenu.classList.remove("open");
559
+ this._settingsScrim.classList.remove("open");
544
560
  }
545
561
 
546
562
  private _buildSettingsMenu(): void {
547
563
  const sections: string[] = [];
548
564
 
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
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
564
579
  const currentRate = this._video.playbackRate ?? 1;
565
- let speedItems = "";
580
+ const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
581
+ let speedOpts = "";
566
582
  for (const spd of PLAYBACK_SPEEDS) {
567
- const active = Math.abs(spd - currentRate) < 0.01;
583
+ const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
568
584
  const label = spd === 1 ? "Normal" : `${spd}x`;
569
- 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"`));
570
597
  }
571
- sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
572
598
 
573
599
  // Subtitle tracks
574
600
  const subs = this._video.subtitleTracks ?? [];
575
601
  if (subs.length > 0) {
576
- let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
602
+ let subOpts = `<option value="-1" selected>Off</option>`;
577
603
  for (const t of subs) {
578
- 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>`;
579
605
  }
580
- 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"`));
581
607
  }
582
608
 
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>`;
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>`;
589
618
  }
590
- 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"`));
591
620
  }
592
621
 
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
- });
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}"`));
606
631
  }
607
- for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
608
- 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) => {
609
652
  e.stopPropagation();
610
- 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
+ }
611
675
  this._buildSettingsMenu();
612
676
  });
613
677
  }
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) => {
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) => {
632
683
  e.stopPropagation();
633
684
  this._toggleStats();
634
685
  this._closeSettings();
@@ -750,10 +801,17 @@ export class AvbridgePlayerElement extends HTMLElement {
750
801
  }
751
802
 
752
803
  private _onContainerClick(e: MouseEvent): void {
753
- // Ignore clicks on controls
804
+ // Ignore clicks on controls and slotted content
754
805
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
755
806
  if (this._isSlottedContentEvent(e)) return;
756
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
+ }
814
+
757
815
  // Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
758
816
  // The browser fires a synthetic click after touchend — skip it.
759
817
  if (this._lastPointerTypeWasTouch) {
@@ -799,6 +857,12 @@ export class AvbridgePlayerElement extends HTMLElement {
799
857
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
800
858
  if (this._isSlottedContentEvent(e)) return;
801
859
 
860
+ // If the bottom sheet is open, dismiss it on any touch outside.
861
+ if (this._settingsOpen) {
862
+ this._closeSettings();
863
+ return;
864
+ }
865
+
802
866
  // Double-tap detection
803
867
  const now = Date.now();
804
868
  if (now - this._lastTapTime < 300) {
@@ -991,6 +1055,17 @@ export class AvbridgePlayerElement extends HTMLElement {
991
1055
  return this._video.destroy();
992
1056
  }
993
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
+ }
994
1069
  async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
995
1070
  getDiagnostics(): unknown { return this._video.getDiagnostics(); }
996
1071
  canPlayType(mime: string): string { return this._video.canPlayType(mime); }
@@ -34,7 +34,6 @@ export const PLAYER_STYLES = /* css */ `
34
34
  position: relative;
35
35
  width: 100%;
36
36
  height: 100%;
37
- cursor: pointer;
38
37
  -webkit-tap-highlight-color: transparent;
39
38
  user-select: none;
40
39
  }
@@ -484,62 +483,113 @@ export const PLAYER_STYLES = /* css */ `
484
483
 
485
484
  .avp-spacer { flex: 1; }
486
485
 
487
- /* ── Settings menu ────────────────────────────────────────────────────── */
486
+ /* ── Settings bottom sheet ────────────────────────────────────────────── */
487
+
488
+ /* Scrim — semi-transparent overlay behind the sheet, above the video.
489
+ Tapping it dismisses the sheet. */
490
+ .avp-settings-scrim {
491
+ position: absolute;
492
+ inset: 0;
493
+ z-index: 9;
494
+ background: rgba(0, 0, 0, 0.4);
495
+ opacity: 0;
496
+ pointer-events: none;
497
+ transition: opacity 0.2s;
498
+ }
488
499
 
500
+ .avp-settings-scrim.open {
501
+ opacity: 1;
502
+ pointer-events: auto;
503
+ }
504
+
505
+ /* Sheet container — slides up from the bottom. Height is content-driven
506
+ up to a JS-measured max (set on open via style.maxHeight). */
489
507
  .avp-settings {
490
508
  position: absolute;
491
- bottom: 52px;
492
- right: 12px;
493
- background: rgba(28, 28, 28, 0.95);
494
- border-radius: 8px;
495
- min-width: 220px;
496
- /* Fit within the player: leave room for the controls bar (52px bottom)
497
- and a small top margin (8px). On tall players this caps at 300px;
498
- on short players it shrinks to whatever fits. */
499
- max-height: min(300px, calc(100% - 52px - 8px));
500
- overflow-y: auto;
501
- display: none;
509
+ bottom: 0;
510
+ left: 0;
511
+ right: 0;
502
512
  z-index: 10;
503
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
513
+ background: rgba(28, 28, 28, 0.97);
514
+ border-radius: 12px 12px 0 0;
515
+ overflow-y: auto;
516
+ overscroll-behavior: contain;
517
+ transform: translateY(100%);
518
+ transition: transform 0.2s ease-out;
519
+ max-height: 70%;
520
+ padding-bottom: 52px;
521
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
504
522
  }
505
523
 
506
- .avp-settings.open { display: block; }
524
+ .avp-settings.open {
525
+ transform: translateY(0);
526
+ }
507
527
 
508
- .avp-settings-section {
509
- padding: 8px 0;
510
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
528
+ /* Drag handle indicator at top of sheet. */
529
+ .avp-settings-handle {
530
+ width: 36px;
531
+ height: 4px;
532
+ border-radius: 2px;
533
+ background: rgba(255, 255, 255, 0.3);
534
+ margin: 8px auto 4px;
511
535
  }
512
536
 
513
- .avp-settings-section:last-child { border-bottom: none; }
537
+ /* ── Accordion sections ──────────────────────────────────────────────── */
514
538
 
515
- .avp-settings-label {
516
- padding: 4px 16px;
517
- font-size: 11px;
518
- text-transform: uppercase;
519
- letter-spacing: 0.5px;
520
- opacity: 0.5;
539
+ .avp-settings-section {
540
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
521
541
  }
522
542
 
523
- .avp-settings-item {
543
+ .avp-settings-section:last-child { border-bottom: none; }
544
+
545
+ /* Section header — clickable row showing label + current value. */
546
+ .avp-settings-header {
547
+ position: relative;
524
548
  display: flex;
525
549
  align-items: center;
526
- padding: 8px 16px;
527
- font-size: 13px;
550
+ justify-content: space-between;
551
+ padding: 12px 16px;
528
552
  cursor: pointer;
553
+ font-size: 14px;
529
554
  transition: background 0.1s;
530
555
  }
531
556
 
532
- .avp-settings-item:hover { background: rgba(255, 255, 255, 0.1); }
557
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
533
558
 
534
- .avp-settings-item.active {
535
- color: #3ea6ff;
559
+ .avp-settings-header-label {
560
+ display: flex;
561
+ align-items: center;
562
+ gap: 8px;
563
+ font-weight: 500;
536
564
  }
537
565
 
538
- .avp-settings-item.active::before {
539
- content: "\\2713";
540
- margin-right: 8px;
541
- font-weight: bold;
566
+ .avp-settings-header-value {
567
+ margin-left: auto;
568
+ opacity: 0.6;
569
+ font-size: 13px;
570
+ text-align: right;
571
+ }
572
+
573
+ /* Invisible native <select> layered over the value portion of the row.
574
+ Covers from the value text to the right edge so tapping the value
575
+ opens the OS picker. The label side remains inert. */
576
+ .avp-settings-select {
577
+ position: absolute;
578
+ top: 0;
579
+ right: 0;
580
+ bottom: 0;
581
+ width: 50%;
582
+ opacity: 0;
583
+ cursor: pointer;
584
+ font-size: 16px;
585
+ direction: rtl;
586
+ }
587
+
588
+ /* Toggle-style rows (Stats for Nerds) — no select, just clickable. */
589
+ .avp-settings-toggle {
590
+ cursor: pointer;
542
591
  }
592
+ .avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
543
593
 
544
594
  /* ── Stats for nerds ──────────────────────────────────────────────────── */
545
595
 
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export type {
36
36
  HardwareAccelerationHint,
37
37
  FetchFn,
38
38
  TransportConfig,
39
+ SettingsSectionConfig,
39
40
  } from "./types.js";
40
41
 
41
42
  export { classify } from "./classify/index.js";
@@ -80,6 +80,10 @@ export class AudioOutput implements ClockSource {
80
80
  private _volume = 1;
81
81
  /** User-set muted flag. When true, gain is forced to 0. */
82
82
  private _muted = false;
83
+ /** Playback rate. Scales the media clock and each AudioBufferSourceNode's
84
+ * playbackRate so audio pitches up/down accordingly (same as native
85
+ * <video>.playbackRate). Default 1. */
86
+ private _rate = 1;
83
87
 
84
88
  constructor() {
85
89
  this.ctx = new AudioContext();
@@ -107,6 +111,24 @@ export class AudioOutput implements ClockSource {
107
111
  return this._muted;
108
112
  }
109
113
 
114
+ /** Set playback rate. Scales the media clock and pitches audio output
115
+ * (same as native <video>.playbackRate — speed without pitch correction).
116
+ * Rebases the anchor so the clock transition is seamless. */
117
+ setPlaybackRate(rate: number): void {
118
+ if (rate === this._rate) return;
119
+ // Rebase anchor at the current media time before changing rate,
120
+ // so the clock doesn't jump.
121
+ const t = this.now();
122
+ this.mediaTimeOfAnchor = t;
123
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
124
+ this.wallAnchorMs = performance.now();
125
+ this._rate = rate;
126
+ }
127
+
128
+ getPlaybackRate(): number {
129
+ return this._rate;
130
+ }
131
+
110
132
  private applyGain(): void {
111
133
  const target = this._muted ? 0 : this._volume;
112
134
  try { this.gain.gain.value = target; } catch { /* ignore */ }
@@ -127,12 +149,12 @@ export class AudioOutput implements ClockSource {
127
149
  now(): number {
128
150
  if (this.noAudio) {
129
151
  if (this.state === "playing") {
130
- return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000;
152
+ return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000 * this._rate;
131
153
  }
132
154
  return this.mediaTimeOfAnchor;
133
155
  }
134
156
  if (this.state === "playing") {
135
- return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
157
+ return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
136
158
  }
137
159
  return this.mediaTimeOfAnchor;
138
160
  }
@@ -200,9 +222,12 @@ export class AudioOutput implements ClockSource {
200
222
  const node = this.ctx.createBufferSource();
201
223
  node.buffer = buffer;
202
224
  node.connect(this.gain);
225
+ // Pitch the audio to match the playback rate (same as native <video>).
226
+ if (this._rate !== 1) node.playbackRate.value = this._rate;
203
227
 
204
- // Convert media time → ctx time using the anchor.
205
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
228
+ // Convert media time → ctx time using the anchor + rate. At rate=2,
229
+ // each second of media time occupies 0.5s of ctx time.
230
+ let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
206
231
 
207
232
  // When the decoder is slower than realtime, `ctxStart` falls into
208
233
  // the past (ctx.currentTime has already passed it). Clamping each
@@ -128,6 +128,17 @@ export async function createFallbackSession(
128
128
  get: () => ctx.duration ?? NaN,
129
129
  });
130
130
  }
131
+ // Playback rate — canvas strategies don't use the real <video>, so the
132
+ // native playbackRate property does nothing. Patch it to drive the
133
+ // AudioOutput clock speed + pitch.
134
+ Object.defineProperty(target, "playbackRate", {
135
+ configurable: true,
136
+ get: () => audio.getPlaybackRate(),
137
+ set: (v: number) => {
138
+ audio.setPlaybackRate(v);
139
+ target.dispatchEvent(new Event("ratechange"));
140
+ },
141
+ });
131
142
  // Synthesize HTMLMediaElement parity surfaces that the canvas strategies
132
143
  // can't otherwise answer truthfully (the inner <video> has no src, so
133
144
  // its own readyState/seekable are zero/empty).
@@ -351,6 +362,7 @@ export async function createFallbackSession(
351
362
  delete (target as unknown as Record<string, unknown>).muted;
352
363
  delete (target as unknown as Record<string, unknown>).readyState;
353
364
  delete (target as unknown as Record<string, unknown>).seekable;
365
+ delete (target as unknown as Record<string, unknown>).playbackRate;
354
366
  } catch { /* ignore */ }
355
367
  },
356
368
 
@@ -84,6 +84,14 @@ export async function createHybridSession(
84
84
  get: () => ctx.duration ?? NaN,
85
85
  });
86
86
  }
87
+ Object.defineProperty(target, "playbackRate", {
88
+ configurable: true,
89
+ get: () => audio.getPlaybackRate(),
90
+ set: (v: number) => {
91
+ audio.setPlaybackRate(v);
92
+ target.dispatchEvent(new Event("ratechange"));
93
+ },
94
+ });
87
95
  // HTMLMediaElement parity surfaces — see fallback/index.ts for rationale.
88
96
  Object.defineProperty(target, "readyState", {
89
97
  configurable: true,
@@ -213,6 +221,7 @@ export async function createHybridSession(
213
221
  delete (target as unknown as Record<string, unknown>).muted;
214
222
  delete (target as unknown as Record<string, unknown>).readyState;
215
223
  delete (target as unknown as Record<string, unknown>).seekable;
224
+ delete (target as unknown as Record<string, unknown>).playbackRate;
216
225
  } catch { /* ignore */ }
217
226
  },
218
227
 
package/src/types.ts CHANGED
@@ -465,3 +465,28 @@ export interface ConvertResult {
465
465
  */
466
466
  notes?: string[];
467
467
  }
468
+
469
+ // ── Settings extensibility ──────────────────────────────────────────────
470
+
471
+ /**
472
+ * Configuration for a custom settings section added to `<avbridge-player>`
473
+ * via {@link addSettingsSection}. Sections render in the bottom-sheet
474
+ * settings panel alongside built-in sections (Speed, Audio, Subtitles,
475
+ * Fit, Stats for Nerds). The player owns rendering — consumers describe
476
+ * data; avbridge renders it in a consistent visual style.
477
+ */
478
+ export interface SettingsSectionConfig {
479
+ /** Unique id for this section. Used to update/remove later. */
480
+ id: string;
481
+ /** Display label (e.g. "Quality", "Translate"). */
482
+ label: string;
483
+ /** Items to show when the section is expanded. */
484
+ items: Array<{
485
+ id: string;
486
+ label: string;
487
+ /** Mark the currently-selected item. */
488
+ active?: boolean;
489
+ }>;
490
+ /** Called when the user picks an item. */
491
+ onSelect(itemId: string): void;
492
+ }