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,273 @@
1
+ /**
2
+ * `<avbridge-subtitles>` — scrollable subtitle timeline panel.
3
+ *
4
+ * Connects to an `<avbridge-player>` or `<avbridge-video>` via the `for`
5
+ * attribute (points to the player's `id`) or auto-detects a sibling.
6
+ * Reads TextTrack cues from the player's inner `<video>`, renders them
7
+ * as a timestamped list, highlights the active cue, and seeks on click.
8
+ *
9
+ * Usage:
10
+ * <avbridge-player id="player">...</avbridge-player>
11
+ * <avbridge-subtitles for="player"></avbridge-subtitles>
12
+ */
13
+
14
+ const HTMLElementCtor: typeof HTMLElement =
15
+ typeof HTMLElement !== "undefined"
16
+ ? HTMLElement
17
+ : (class {} as unknown as typeof HTMLElement);
18
+
19
+ const STYLES = `
20
+ :host {
21
+ display: block;
22
+ font-family: system-ui, -apple-system, sans-serif;
23
+ font-size: 14px;
24
+ color: #eee;
25
+ background: #1a1a1a;
26
+ overflow-y: auto;
27
+ overscroll-behavior: contain;
28
+ }
29
+
30
+ .avs-empty {
31
+ padding: 16px;
32
+ opacity: 0.5;
33
+ text-align: center;
34
+ font-size: 13px;
35
+ }
36
+
37
+ .avs-cue {
38
+ display: flex;
39
+ gap: 12px;
40
+ padding: 8px 12px;
41
+ cursor: pointer;
42
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
43
+ transition: background 0.1s;
44
+ align-items: flex-start;
45
+ }
46
+
47
+ .avs-cue:hover {
48
+ background: rgba(255, 255, 255, 0.06);
49
+ }
50
+
51
+ .avs-cue.active {
52
+ background: rgba(62, 166, 255, 0.12);
53
+ }
54
+
55
+ .avs-time {
56
+ flex-shrink: 0;
57
+ font-size: 12px;
58
+ font-variant-numeric: tabular-nums;
59
+ opacity: 0.5;
60
+ min-width: 48px;
61
+ padding-top: 1px;
62
+ }
63
+
64
+ .avs-cue.active .avs-time {
65
+ opacity: 0.8;
66
+ color: #3ea6ff;
67
+ }
68
+
69
+ .avs-text {
70
+ flex: 1;
71
+ min-width: 0;
72
+ line-height: 1.4;
73
+ word-break: break-word;
74
+ }
75
+ `;
76
+
77
+ interface CueEntry {
78
+ start: number;
79
+ end: number;
80
+ text: string;
81
+ el: HTMLDivElement;
82
+ }
83
+
84
+ export class AvbridgeSubtitlesElement extends HTMLElementCtor {
85
+ static readonly observedAttributes = ["for"];
86
+
87
+ private _player: HTMLElement | null = null;
88
+ private _cues: CueEntry[] = [];
89
+ private _tickTimer: ReturnType<typeof setInterval> | null = null;
90
+ private _activeCueIndex = -1;
91
+ private _trackChangeListener: (() => void) | null = null;
92
+
93
+ constructor() {
94
+ super();
95
+ const shadow = this.attachShadow({ mode: "open" });
96
+ shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No subtitles loaded</div>`;
97
+ }
98
+
99
+ connectedCallback(): void {
100
+ this._connectPlayer();
101
+ this._startTick();
102
+ }
103
+
104
+ disconnectedCallback(): void {
105
+ this._stopTick();
106
+ this._disconnectPlayer();
107
+ }
108
+
109
+ attributeChangedCallback(name: string): void {
110
+ if (name === "for") {
111
+ this._disconnectPlayer();
112
+ this._connectPlayer();
113
+ }
114
+ }
115
+
116
+ // ── Player connection ──────────────────────────────────────────────────
117
+
118
+ private _connectPlayer(): void {
119
+ const forId = this.getAttribute("for");
120
+ if (forId) {
121
+ this._player = document.getElementById(forId);
122
+ } else {
123
+ // Auto-detect sibling avbridge-player or avbridge-video
124
+ this._player =
125
+ this.parentElement?.querySelector("avbridge-player") ??
126
+ this.parentElement?.querySelector("avbridge-video") ??
127
+ null;
128
+ }
129
+ if (!this._player) return;
130
+
131
+ // Listen for trackschange to rebuild the cue list when subtitles
132
+ // are added/removed dynamically.
133
+ this._trackChangeListener = () => this._rebuildCues();
134
+ this._player.addEventListener("trackschange", this._trackChangeListener);
135
+
136
+ // Initial build (subtitle may already be loaded).
137
+ // Defer so the player has time to bootstrap.
138
+ requestAnimationFrame(() => this._rebuildCues());
139
+ }
140
+
141
+ private _disconnectPlayer(): void {
142
+ if (this._player && this._trackChangeListener) {
143
+ this._player.removeEventListener("trackschange", this._trackChangeListener);
144
+ }
145
+ this._player = null;
146
+ this._trackChangeListener = null;
147
+ }
148
+
149
+ private _getVideoElement(): HTMLVideoElement | null {
150
+ if (!this._player) return null;
151
+ return (this._player as unknown as { videoElement?: HTMLVideoElement }).videoElement ?? null;
152
+ }
153
+
154
+ // ── Cue list ───────────────────────────────────────────────────────────
155
+
156
+ private _rebuildCues(): void {
157
+ const video = this._getVideoElement();
158
+ const shadow = this.shadowRoot!;
159
+ this._cues = [];
160
+ this._activeCueIndex = -1;
161
+
162
+ if (!video) {
163
+ shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No player connected</div>`;
164
+ return;
165
+ }
166
+
167
+ // Find the first subtitle/caption track with cues.
168
+ let track: TextTrack | null = null;
169
+ for (let i = 0; i < video.textTracks.length; i++) {
170
+ const t = video.textTracks[i];
171
+ if ((t.kind === "subtitles" || t.kind === "captions") && t.cues && t.cues.length > 0) {
172
+ track = t;
173
+ break;
174
+ }
175
+ }
176
+
177
+ if (!track || !track.cues || track.cues.length === 0) {
178
+ shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No subtitle cues available</div>`;
179
+ // Retry shortly — cues may load async.
180
+ setTimeout(() => {
181
+ if (this._cues.length === 0 && this.isConnected) this._rebuildCues();
182
+ }, 1000);
183
+ return;
184
+ }
185
+
186
+ // Build the list.
187
+ const container = document.createElement("div");
188
+ for (let i = 0; i < track.cues.length; i++) {
189
+ const cue = track.cues[i] as VTTCue;
190
+ const el = document.createElement("div");
191
+ el.className = "avs-cue";
192
+
193
+ const timeEl = document.createElement("span");
194
+ timeEl.className = "avs-time";
195
+ timeEl.textContent = formatTime(cue.startTime);
196
+
197
+ const textEl = document.createElement("span");
198
+ textEl.className = "avs-text";
199
+ textEl.textContent = cue.text.replace(/<[^>]+>/g, "");
200
+
201
+ el.appendChild(timeEl);
202
+ el.appendChild(textEl);
203
+
204
+ const startTime = cue.startTime;
205
+ el.addEventListener("click", () => {
206
+ if (this._player) {
207
+ (this._player as unknown as { currentTime: number }).currentTime = startTime;
208
+ }
209
+ });
210
+
211
+ container.appendChild(el);
212
+ this._cues.push({ start: cue.startTime, end: cue.endTime, text: cue.text, el });
213
+ }
214
+
215
+ shadow.innerHTML = `<style>${STYLES}</style>`;
216
+ shadow.appendChild(container);
217
+ }
218
+
219
+ // ── Active cue tracking ────────────────────────────────────────────────
220
+
221
+ private _startTick(): void {
222
+ if (this._tickTimer) return;
223
+ this._tickTimer = setInterval(() => this._tick(), 250);
224
+ }
225
+
226
+ private _stopTick(): void {
227
+ if (this._tickTimer) {
228
+ clearInterval(this._tickTimer);
229
+ this._tickTimer = null;
230
+ }
231
+ }
232
+
233
+ private _tick(): void {
234
+ if (this._cues.length === 0 || !this._player) return;
235
+ const currentTime = (this._player as unknown as { currentTime: number }).currentTime ?? 0;
236
+
237
+ let newActive = -1;
238
+ for (let i = 0; i < this._cues.length; i++) {
239
+ const c = this._cues[i];
240
+ if (currentTime >= c.start && currentTime <= c.end) {
241
+ newActive = i;
242
+ break;
243
+ }
244
+ }
245
+
246
+ if (newActive === this._activeCueIndex) return;
247
+
248
+ // Remove previous highlight.
249
+ if (this._activeCueIndex >= 0 && this._activeCueIndex < this._cues.length) {
250
+ this._cues[this._activeCueIndex].el.classList.remove("active");
251
+ }
252
+
253
+ this._activeCueIndex = newActive;
254
+
255
+ // Apply new highlight + scroll into view.
256
+ if (newActive >= 0) {
257
+ const cue = this._cues[newActive];
258
+ cue.el.classList.add("active");
259
+ cue.el.scrollIntoView({ block: "center", behavior: "smooth" });
260
+ }
261
+ }
262
+ }
263
+
264
+ function formatTime(sec: number): string {
265
+ if (!Number.isFinite(sec) || sec < 0) sec = 0;
266
+ const total = Math.floor(sec);
267
+ const h = Math.floor(total / 3600);
268
+ const m = Math.floor((total % 3600) / 60);
269
+ const s = total % 60;
270
+ const mm = String(m).padStart(h > 0 ? 2 : 1, "0");
271
+ const ss = String(s).padStart(2, "0");
272
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
273
+ }
@@ -856,15 +856,35 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
856
856
  sidecarUrl: subtitle.url,
857
857
  };
858
858
  this._subtitleTracks.push(track);
859
+ // eslint-disable-next-line no-console
860
+ console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
859
861
  await attachSubtitleTracks(
860
862
  this._videoEl,
861
863
  this._subtitleTracks,
862
864
  undefined,
863
865
  (err, t) => {
864
866
  // eslint-disable-next-line no-console
865
- console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
867
+ console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
866
868
  },
867
869
  );
870
+ // Enable the newly-added track so it renders immediately. On native
871
+ // strategy the <video>'s textTrack must be mode="showing"; on canvas
872
+ // strategies the renderer's watchTextTracks picks it up from the
873
+ // hidden-mode textTracks.
874
+ const textTracks = this._videoEl.textTracks;
875
+ for (let i = 0; i < textTracks.length; i++) {
876
+ if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
877
+ textTracks[i].mode = "showing";
878
+ // eslint-disable-next-line no-console
879
+ console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
880
+ break;
881
+ }
882
+ }
883
+ // Notify the settings sheet so it rebuilds with the new track.
884
+ this._dispatch("trackschange", {
885
+ audioTracks: this._audioTracks,
886
+ subtitleTracks: this.subtitleTracks,
887
+ });
868
888
  }
869
889
 
870
890
  /**
@@ -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
@@ -256,6 +281,10 @@ export class AudioOutput implements ClockSource {
256
281
  await this.ctx.resume();
257
282
  }
258
283
 
284
+ // Reconnect the gain node — pause() disconnects it to cut off
285
+ // in-flight audio instantly. Safe to call even if already connected.
286
+ try { this.gain.connect(this.ctx.destination); } catch { /* ignore */ }
287
+
259
288
  if (this.state === "paused") {
260
289
  // Resume: media time should continue from where we paused. ctx.currentTime
261
290
  // is preserved across suspend/resume, so re-anchoring it to "now" with
@@ -292,6 +321,12 @@ export class AudioOutput implements ClockSource {
292
321
  this.mediaTimeOfAnchor = this.now();
293
322
  this.state = "paused";
294
323
  if (this.noAudio) return;
324
+ // Disconnect the gain node immediately so any in-flight scheduled
325
+ // buffers are silenced instantly. ctx.suspend() is async and
326
+ // already-started AudioBufferSourceNodes keep playing until the
327
+ // context actually suspends — without the disconnect, audio bleeds
328
+ // through for ~200ms after pause().
329
+ try { this.gain.disconnect(); } catch { /* ignore */ }
295
330
  if (this.ctx.state === "running") {
296
331
  await this.ctx.suspend();
297
332
  }
@@ -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
 
@@ -110,6 +110,8 @@ export async function attachSubtitleTracks(
110
110
 
111
111
  for (const t of tracks) {
112
112
  if (!t.sidecarUrl) continue;
113
+ // eslint-disable-next-line no-console
114
+ console.log(`[avbridge:subs] attaching track id=${t.id} format=${t.format} url=${t.sidecarUrl.slice(0, 60)}`);
113
115
  try {
114
116
  let url = t.sidecarUrl;
115
117
  if (t.format === "srt") {
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
+ }