avbridge 2.10.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 (39) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/{chunk-NQULEIA3.cjs → chunk-37UOSAVI.cjs} +15 -7
  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-3GKM5DFM.js → chunk-IHNHHEA2.js} +11 -3
  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 +23 -1
  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.js +17 -4
  15. package/dist/element.js.map +1 -1
  16. package/dist/index.cjs +10 -10
  17. package/dist/index.js +2 -2
  18. package/dist/player.cjs +106 -18
  19. package/dist/player.cjs.map +1 -1
  20. package/dist/player.d.cts +15 -0
  21. package/dist/player.d.ts +15 -0
  22. package/dist/player.js +102 -14
  23. package/dist/player.js.map +1 -1
  24. package/dist/subtitles-5H24MEBJ.js +4 -0
  25. package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
  26. package/dist/subtitles-HMVGWTU2.cjs +29 -0
  27. package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
  28. package/package.json +1 -1
  29. package/src/element/avbridge-player.ts +92 -10
  30. package/src/element/avbridge-subtitles.ts +273 -0
  31. package/src/element/avbridge-video.ts +21 -1
  32. package/src/strategies/fallback/audio-output.ts +10 -0
  33. package/src/subtitles/index.ts +2 -0
  34. package/dist/chunk-3GKM5DFM.js.map +0 -1
  35. package/dist/chunk-5KVLE6YI.js.map +0 -1
  36. package/dist/chunk-NQULEIA3.cjs.map +0 -1
  37. package/dist/chunk-S4WAZC2T.cjs.map +0 -1
  38. package/dist/subtitles-4T74JRGT.js +0 -4
  39. 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.10.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",
@@ -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
 
@@ -101,6 +101,8 @@ export class AvbridgePlayerElement extends HTMLElement {
101
101
  private _state: PlayerState = "idle";
102
102
  private _controlsTimer: ReturnType<typeof setTimeout> | null = null;
103
103
  private _settingsOpen = false;
104
+ private _activeAudioTrackId: number | null = null;
105
+ private _activeSubtitleTrackId: number | null = null;
104
106
  private _userSeeking = false;
105
107
  private _holdTimer: ReturnType<typeof setTimeout> | null = null;
106
108
  private _holdSpeedActive = false;
@@ -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);
@@ -589,21 +612,29 @@ export class AvbridgePlayerElement extends HTMLElement {
589
612
  // Audio tracks
590
613
  const audios = this._video.audioTracks ?? [];
591
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}`;
592
618
  let audioOpts = "";
593
619
  for (const t of audios) {
594
- audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
620
+ const sel = t.id === activeAudioId ? " selected" : "";
621
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
595
622
  }
596
- sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
623
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
597
624
  }
598
625
 
599
626
  // Subtitle tracks
600
627
  const subs = this._video.subtitleTracks ?? [];
601
628
  if (subs.length > 0) {
602
- let subOpts = `<option value="-1" selected>Off</option>`;
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>`;
603
633
  for (const t of subs) {
604
- subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
634
+ const sel = t.id === activeSubId ? " selected" : "";
635
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
605
636
  }
606
- sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
637
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
607
638
  }
608
639
 
609
640
  // Fit mode — opt-in via the `show-fit` attribute
@@ -657,11 +688,15 @@ export class AvbridgePlayerElement extends HTMLElement {
657
688
  this._video.playbackRate = Number(val);
658
689
  break;
659
690
  case "audio":
691
+ this._activeAudioTrackId = Number(val);
660
692
  void this._video.setAudioTrack(Number(val));
661
693
  break;
662
- case "subtitle":
663
- void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
694
+ case "subtitle": {
695
+ const subId = Number(val);
696
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
697
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
664
698
  break;
699
+ }
665
700
  case "fit":
666
701
  this.setAttribute("fit", val);
667
702
  break;
@@ -762,16 +797,28 @@ export class AvbridgePlayerElement extends HTMLElement {
762
797
  this.showControls();
763
798
  }
764
799
 
765
- private _scheduleHide(durationMs: number = CONTROLS_HIDE_MS): void {
800
+ private _scheduleHide(durationMs?: number): void {
801
+ const ms = durationMs ?? this._getControlsTimeout();
766
802
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
767
803
  if (this._state !== "playing" && this._state !== "buffering") return;
768
804
  if (this._settingsOpen) return;
805
+ // A timeout of 0 or negative means "never hide" (controls always visible).
806
+ if (ms <= 0) return;
769
807
  this._controlsTimer = setTimeout(() => {
770
808
  if (this._state === "playing") {
771
809
  this.setAttribute("data-controls-hidden", "");
772
810
  this._toolbarTop.setAttribute("data-visible", "false");
773
811
  }
774
- }, 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;
775
822
  }
776
823
 
777
824
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
@@ -786,6 +833,9 @@ export class AvbridgePlayerElement extends HTMLElement {
786
833
 
787
834
  /** Track whether the last interaction was touch so click handler can skip. */
788
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;
789
839
 
790
840
  /** True if the event's composed path passes through consumer-slotted
791
841
  * content (toolbar or content-overlay). Slotted content lives in the
@@ -830,6 +880,11 @@ export class AvbridgePlayerElement extends HTMLElement {
830
880
  private _onContainerDblClick(e: MouseEvent): void {
831
881
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
832
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;
833
888
  // Cancel the pending single-click play/pause
834
889
  if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
835
890
  this._toggleFullscreen();
@@ -877,6 +932,10 @@ export class AvbridgePlayerElement extends HTMLElement {
877
932
  } else {
878
933
  this._toggleFullscreen();
879
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);
880
939
  this._lastTapTime = 0;
881
940
  return;
882
941
  }
@@ -918,6 +977,14 @@ export class AvbridgePlayerElement extends HTMLElement {
918
977
 
919
978
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
920
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
+
921
988
  private _onKeydown(e: KeyboardEvent): void {
922
989
  // Don't intercept if the user is typing in an input
923
990
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
@@ -964,6 +1031,21 @@ export class AvbridgePlayerElement extends HTMLElement {
964
1031
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
965
1032
  this._buildSettingsMenu();
966
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;
967
1049
  case "Escape":
968
1050
  if (this._settingsOpen) {
969
1051
  e.preventDefault();
@@ -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
  /**
@@ -281,6 +281,10 @@ export class AudioOutput implements ClockSource {
281
281
  await this.ctx.resume();
282
282
  }
283
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
+
284
288
  if (this.state === "paused") {
285
289
  // Resume: media time should continue from where we paused. ctx.currentTime
286
290
  // is preserved across suspend/resume, so re-anchoring it to "now" with
@@ -317,6 +321,12 @@ export class AudioOutput implements ClockSource {
317
321
  this.mediaTimeOfAnchor = this.now();
318
322
  this.state = "paused";
319
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 */ }
320
330
  if (this.ctx.state === "running") {
321
331
  await this.ctx.suspend();
322
332
  }
@@ -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") {