avbridge 2.10.0 → 2.12.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 (91) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-EQE6AR75.cjs} +4 -4
  3. package/dist/{avi-2ILLBNPQ.cjs.map → avi-EQE6AR75.cjs.map} +1 -1
  4. package/dist/{avi-RWWPN2PR.js → avi-NNHH4AAA.js} +3 -3
  5. package/dist/{avi-JXU4GQL2.js.map → avi-NNHH4AAA.js.map} +1 -1
  6. package/dist/{avi-JXU4GQL2.js → avi-S7EY54YA.js} +3 -3
  7. package/dist/{avi-RWWPN2PR.js.map → avi-S7EY54YA.js.map} +1 -1
  8. package/dist/{avi-2ILLBNPQ.cjs → avi-Y3N325WZ.cjs} +4 -4
  9. package/dist/{avi-B5CQYB7L.cjs.map → avi-Y3N325WZ.cjs.map} +1 -1
  10. package/dist/{chunk-GYIJU44C.js → chunk-2LNXMGT6.js} +5 -5
  11. package/dist/{chunk-GYIJU44C.js.map → chunk-2LNXMGT6.js.map} +1 -1
  12. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  13. package/dist/chunk-3AI5WFFN.js.map +1 -0
  14. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  15. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-5Y5BTB5D.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-5Y5BTB5D.js.map} +1 -1
  18. package/dist/{chunk-NQULEIA3.cjs → chunk-7EF4VTUS.cjs} +36 -28
  19. package/dist/chunk-7EF4VTUS.cjs.map +1 -0
  20. package/dist/{chunk-5KVLE6YI.js → chunk-EDDWAN2L.js} +3 -2
  21. package/dist/chunk-EDDWAN2L.js.map +1 -0
  22. package/dist/{chunk-OTFS7DC4.cjs → chunk-GJBNLPGI.cjs} +14 -14
  23. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-GJBNLPGI.cjs.map} +1 -1
  24. package/dist/{chunk-BYGZN4Z5.cjs → chunk-HBHSUGNI.cjs} +5 -5
  25. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-HBHSUGNI.cjs.map} +1 -1
  26. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  27. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  28. package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
  29. package/dist/chunk-WRKO6Q42.cjs.map +1 -0
  30. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  31. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  32. package/dist/{chunk-3GKM5DFM.js → chunk-Z26PXRUY.js} +18 -10
  33. package/dist/chunk-Z26PXRUY.js.map +1 -0
  34. package/dist/element-browser.js +65 -19
  35. package/dist/element-browser.js.map +1 -1
  36. package/dist/element.cjs +21 -8
  37. package/dist/element.cjs.map +1 -1
  38. package/dist/element.d.cts +1 -1
  39. package/dist/element.d.ts +1 -1
  40. package/dist/element.js +20 -7
  41. package/dist/element.js.map +1 -1
  42. package/dist/index.cjs +23 -23
  43. package/dist/index.d.cts +2 -2
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +10 -10
  46. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  47. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  48. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  49. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  50. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  51. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  52. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  53. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  54. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  55. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  56. package/dist/player.cjs +166 -33
  57. package/dist/player.cjs.map +1 -1
  58. package/dist/player.d.cts +36 -0
  59. package/dist/player.d.ts +36 -0
  60. package/dist/player.js +162 -29
  61. package/dist/player.js.map +1 -1
  62. package/dist/remux-7TA4FKTY.js +10 -0
  63. package/dist/{remux-56V7LDAD.js.map → remux-7TA4FKTY.js.map} +1 -1
  64. package/dist/remux-VPKCLHHM.cjs +35 -0
  65. package/dist/{remux-KUS5GIL6.cjs.map → remux-VPKCLHHM.cjs.map} +1 -1
  66. package/dist/subtitles-5H24MEBJ.js +4 -0
  67. package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
  68. package/dist/subtitles-HMVGWTU2.cjs +29 -0
  69. package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
  70. package/package.json +1 -1
  71. package/src/element/avbridge-player.ts +128 -18
  72. package/src/element/avbridge-subtitles.ts +273 -0
  73. package/src/element/avbridge-video.ts +21 -1
  74. package/src/element/player-styles.ts +13 -1
  75. package/src/player.ts +3 -3
  76. package/src/strategies/fallback/audio-output.ts +10 -0
  77. package/src/subtitles/index.ts +2 -0
  78. package/src/types.ts +15 -0
  79. package/src/util/libav-http-reader.ts +58 -19
  80. package/dist/chunk-3GKM5DFM.js.map +0 -1
  81. package/dist/chunk-5KVLE6YI.js.map +0 -1
  82. package/dist/chunk-DCSOQH2N.js.map +0 -1
  83. package/dist/chunk-NQULEIA3.cjs.map +0 -1
  84. package/dist/chunk-S4WAZC2T.cjs.map +0 -1
  85. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  86. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  87. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  88. package/dist/remux-56V7LDAD.js +0 -10
  89. package/dist/remux-KUS5GIL6.cjs +0 -35
  90. package/dist/subtitles-4T74JRGT.js +0 -4
  91. package/dist/subtitles-QUH4LPI4.cjs +0 -29
@@ -0,0 +1,10 @@
1
+ export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-5Y5BTB5D.js';
2
+ import './chunk-2LNXMGT6.js';
3
+ import './chunk-CPJLFFCC.js';
4
+ import './chunk-LUFA47FP.js';
5
+ import './chunk-3YKWU4FM.js';
6
+ import './chunk-3AI5WFFN.js';
7
+ import './chunk-5DMTJVIU.js';
8
+ import './chunk-5YAWWKA3.js';
9
+ //# sourceMappingURL=remux-7TA4FKTY.js.map
10
+ //# sourceMappingURL=remux-7TA4FKTY.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-56V7LDAD.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-7TA4FKTY.js"}
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ var chunkGJBNLPGI_cjs = require('./chunk-GJBNLPGI.cjs');
4
+ require('./chunk-HBHSUGNI.cjs');
5
+ require('./chunk-2IJ66NTD.cjs');
6
+ require('./chunk-QDJLQR53.cjs');
7
+ require('./chunk-HZUVMXBN.cjs');
8
+ require('./chunk-YPZFGJV3.cjs');
9
+ require('./chunk-G4APZMCP.cjs');
10
+ require('./chunk-F3LQJKXK.cjs');
11
+
12
+
13
+
14
+ Object.defineProperty(exports, "createOutputFormat", {
15
+ enumerable: true,
16
+ get: function () { return chunkGJBNLPGI_cjs.createOutputFormat; }
17
+ });
18
+ Object.defineProperty(exports, "generateFilename", {
19
+ enumerable: true,
20
+ get: function () { return chunkGJBNLPGI_cjs.generateFilename; }
21
+ });
22
+ Object.defineProperty(exports, "mimeForFormat", {
23
+ enumerable: true,
24
+ get: function () { return chunkGJBNLPGI_cjs.mimeForFormat; }
25
+ });
26
+ Object.defineProperty(exports, "remux", {
27
+ enumerable: true,
28
+ get: function () { return chunkGJBNLPGI_cjs.remux; }
29
+ });
30
+ Object.defineProperty(exports, "validateRemuxEligibility", {
31
+ enumerable: true,
32
+ get: function () { return chunkGJBNLPGI_cjs.validateRemuxEligibility; }
33
+ });
34
+ //# sourceMappingURL=remux-VPKCLHHM.cjs.map
35
+ //# sourceMappingURL=remux-VPKCLHHM.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-KUS5GIL6.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-VPKCLHHM.cjs"}
@@ -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.12.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;
@@ -245,6 +247,11 @@ export class AvbridgePlayerElement extends HTMLElement {
245
247
  on(this._video, "ended", () => this._setState("ended"));
246
248
  on(this._video, "error", () => this._setState("error"));
247
249
  on(this._video, "timeupdate", () => this._updateTime());
250
+ // `progress` fires as the inner element's buffered ranges grow — keep the
251
+ // seek bar's buffered indicator fresh even when paused or filling ahead
252
+ // without timeupdate advancing. `<avbridge-video>` dispatches this on
253
+ // all strategies (including the synthesized ranges for canvas strategies).
254
+ on(this._video, "progress", () => this._updateBuffered());
248
255
  on(this._video, "volumechange", () => this._updateVolume());
249
256
  // Strategy changes are visible in Stats for Nerds.
250
257
  on(this._video, "trackschange", () => this._buildSettingsMenu());
@@ -435,6 +442,11 @@ export class AvbridgePlayerElement extends HTMLElement {
435
442
  return frac * (this._video.duration || 0);
436
443
  }
437
444
 
445
+ /** Seekbar width below which drag-to-scrub seeks in real-time (vs
446
+ * preview-only). On narrow bars precise positioning is hard, so
447
+ * immediate video feedback is more useful than a time tooltip. */
448
+ private static readonly SCRUB_WIDTH_THRESHOLD = 400;
449
+
438
450
  private _onSeekPointerDown(e: PointerEvent): void {
439
451
  // Ignore synthetic clicks originating from the input's own handling
440
452
  if (e.button !== 0 && e.pointerType === "mouse") return;
@@ -444,16 +456,32 @@ export class AvbridgePlayerElement extends HTMLElement {
444
456
  seekBar.setPointerCapture(e.pointerId);
445
457
  seekBar.setAttribute("data-seeking", "");
446
458
 
459
+ // Decide scrub mode based on physical width.
460
+ const scrubMode = seekBar.getBoundingClientRect().width < AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
461
+ let lastScrubCommit = 0;
462
+
447
463
  const initial = this._timeFromSeekPointer(e.clientX);
448
464
  this._seekInput.value = String(initial);
449
465
  this._onSeekInput();
450
466
  this._updateSeekTooltip(e.clientX);
467
+ if (scrubMode) this._onSeekCommit();
451
468
 
452
469
  const onMove = (ev: PointerEvent) => {
453
470
  const t = this._timeFromSeekPointer(ev.clientX);
454
471
  this._seekInput.value = String(t);
455
472
  this._onSeekInput();
456
473
  this._updateSeekTooltip(ev.clientX);
474
+ // In scrub mode, commit seeks throttled to ~4 Hz so we don't
475
+ // overwhelm the seek pipeline (especially on canvas strategies
476
+ // where each seek restarts the decoder pump).
477
+ if (scrubMode) {
478
+ const now = performance.now();
479
+ if (now - lastScrubCommit > 250) {
480
+ lastScrubCommit = now;
481
+ this._onSeekCommit();
482
+ this._userSeeking = true; // keep seeking flag live
483
+ }
484
+ }
457
485
  };
458
486
  const onUp = (ev: PointerEvent) => {
459
487
  const t = this._timeFromSeekPointer(ev.clientX);
@@ -501,15 +529,38 @@ export class AvbridgePlayerElement extends HTMLElement {
501
529
  this._seekInput.value = String(t);
502
530
  this._updateSeekVisuals(t);
503
531
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
532
+ this._updateBuffered();
533
+ }
504
534
 
505
- // Buffered ranges
506
- try {
507
- const buf = this._video.buffered;
508
- if (buf && buf.length > 0 && d > 0) {
509
- const end = buf.end(buf.length - 1);
510
- this._seekBuffered.style.width = `${(end / d) * 100}%`;
511
- }
512
- } catch { /* ignore */ }
535
+ /**
536
+ * Render every buffered range as its own segment so gaps (common on MSE
537
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
538
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
539
+ */
540
+ private _updateBuffered(): void {
541
+ const d = this._video.duration;
542
+ if (!(d > 0)) return;
543
+ let buf: TimeRanges;
544
+ try { buf = this._video.buffered; } catch { return; }
545
+ const count = buf ? buf.length : 0;
546
+ const host = this._seekBuffered;
547
+ // Reconcile child count. Segment divs are styled via .avp-seek-buffered-range.
548
+ while (host.childElementCount > count) host.lastElementChild!.remove();
549
+ while (host.childElementCount < count) {
550
+ const seg = document.createElement("div");
551
+ seg.className = "avp-seek-buffered-range";
552
+ host.appendChild(seg);
553
+ }
554
+ for (let i = 0; i < count; i++) {
555
+ let start: number; let end: number;
556
+ try { start = buf.start(i); end = buf.end(i); } catch { continue; }
557
+ const s = Math.max(0, start);
558
+ const e = Math.min(d, end);
559
+ if (e <= s) continue;
560
+ const seg = host.children[i] as HTMLElement;
561
+ seg.style.left = `${(s / d) * 100}%`;
562
+ seg.style.width = `${((e - s) / d) * 100}%`;
563
+ }
513
564
  }
514
565
 
515
566
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -589,21 +640,29 @@ export class AvbridgePlayerElement extends HTMLElement {
589
640
  // Audio tracks
590
641
  const audios = this._video.audioTracks ?? [];
591
642
  if (audios.length > 1) {
643
+ const activeAudioId = this._activeAudioTrackId ?? audios[0]?.id;
644
+ const activeAudio = audios.find((t: { id: number }) => t.id === activeAudioId) ?? audios[0];
645
+ const audioValue = activeAudio?.language ?? `Track ${activeAudio?.id ?? 1}`;
592
646
  let audioOpts = "";
593
647
  for (const t of audios) {
594
- audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
648
+ const sel = t.id === activeAudioId ? " selected" : "";
649
+ audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
595
650
  }
596
- sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
651
+ sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
597
652
  }
598
653
 
599
654
  // Subtitle tracks
600
655
  const subs = this._video.subtitleTracks ?? [];
601
656
  if (subs.length > 0) {
602
- let subOpts = `<option value="-1" selected>Off</option>`;
657
+ const activeSubId = this._activeSubtitleTrackId;
658
+ const activeSub = activeSubId != null ? subs.find((t: { id: number }) => t.id === activeSubId) : null;
659
+ const subValue = activeSub ? (activeSub.language ?? `Track ${activeSub.id}`) : "Off";
660
+ let subOpts = `<option value="-1"${activeSubId == null ? " selected" : ""}>Off</option>`;
603
661
  for (const t of subs) {
604
- subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
662
+ const sel = t.id === activeSubId ? " selected" : "";
663
+ subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
605
664
  }
606
- sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
665
+ sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
607
666
  }
608
667
 
609
668
  // Fit mode — opt-in via the `show-fit` attribute
@@ -657,11 +716,15 @@ export class AvbridgePlayerElement extends HTMLElement {
657
716
  this._video.playbackRate = Number(val);
658
717
  break;
659
718
  case "audio":
719
+ this._activeAudioTrackId = Number(val);
660
720
  void this._video.setAudioTrack(Number(val));
661
721
  break;
662
- case "subtitle":
663
- void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
722
+ case "subtitle": {
723
+ const subId = Number(val);
724
+ this._activeSubtitleTrackId = subId >= 0 ? subId : null;
725
+ void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
664
726
  break;
727
+ }
665
728
  case "fit":
666
729
  this.setAttribute("fit", val);
667
730
  break;
@@ -762,16 +825,28 @@ export class AvbridgePlayerElement extends HTMLElement {
762
825
  this.showControls();
763
826
  }
764
827
 
765
- private _scheduleHide(durationMs: number = CONTROLS_HIDE_MS): void {
828
+ private _scheduleHide(durationMs?: number): void {
829
+ const ms = durationMs ?? this._getControlsTimeout();
766
830
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
767
831
  if (this._state !== "playing" && this._state !== "buffering") return;
768
832
  if (this._settingsOpen) return;
833
+ // A timeout of 0 or negative means "never hide" (controls always visible).
834
+ if (ms <= 0) return;
769
835
  this._controlsTimer = setTimeout(() => {
770
836
  if (this._state === "playing") {
771
837
  this.setAttribute("data-controls-hidden", "");
772
838
  this._toolbarTop.setAttribute("data-visible", "false");
773
839
  }
774
- }, durationMs);
840
+ }, ms);
841
+ }
842
+
843
+ /** Read the controls-timeout attribute. 0 or negative = never hide.
844
+ * Unset = default 3000ms. */
845
+ private _getControlsTimeout(): number {
846
+ const attr = this.getAttribute("controls-timeout");
847
+ if (attr == null) return DEFAULT_CONTROLS_HIDE_MS;
848
+ const n = Number(attr);
849
+ return Number.isFinite(n) ? n : DEFAULT_CONTROLS_HIDE_MS;
775
850
  }
776
851
 
777
852
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
@@ -786,6 +861,9 @@ export class AvbridgePlayerElement extends HTMLElement {
786
861
 
787
862
  /** Track whether the last interaction was touch so click handler can skip. */
788
863
  private _lastPointerTypeWasTouch = false;
864
+ /** True for ~50ms after a touch double-tap was handled, so the
865
+ * synthetic dblclick from the browser doesn't also fire fullscreen. */
866
+ private _touchDoubleTapConsumed = false;
789
867
 
790
868
  /** True if the event's composed path passes through consumer-slotted
791
869
  * content (toolbar or content-overlay). Slotted content lives in the
@@ -830,6 +908,11 @@ export class AvbridgePlayerElement extends HTMLElement {
830
908
  private _onContainerDblClick(e: MouseEvent): void {
831
909
  if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
832
910
  if (this._isSlottedContentEvent(e)) return;
911
+ // On touch devices, the browser synthesizes a dblclick after two
912
+ // rapid taps. But we already handled the double-tap in _onPointerUp
913
+ // (which does ff/rw on sides, fullscreen in center). Skip the
914
+ // synthetic dblclick so both don't fire.
915
+ if (this._touchDoubleTapConsumed) return;
833
916
  // Cancel the pending single-click play/pause
834
917
  if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
835
918
  this._toggleFullscreen();
@@ -877,6 +960,10 @@ export class AvbridgePlayerElement extends HTMLElement {
877
960
  } else {
878
961
  this._toggleFullscreen();
879
962
  }
963
+ // Prevent the synthetic dblclick (fired ~50ms later by the
964
+ // browser) from also toggling fullscreen.
965
+ this._touchDoubleTapConsumed = true;
966
+ setTimeout(() => { this._touchDoubleTapConsumed = false; }, 100);
880
967
  this._lastTapTime = 0;
881
968
  return;
882
969
  }
@@ -918,6 +1005,14 @@ export class AvbridgePlayerElement extends HTMLElement {
918
1005
 
919
1006
  // ── Keyboard shortcuts ─────────────────────────────────────────────────
920
1007
 
1008
+ /** Duration of one frame in seconds, derived from diagnostics fps or
1009
+ * a 30fps default. Used for frame-step shortcuts (`,` / `.`). */
1010
+ private _frameDuration(): number {
1011
+ const diag = this._video.getDiagnostics() as { fps?: number } | null;
1012
+ const fps = diag?.fps && diag.fps > 0 ? diag.fps : 30;
1013
+ return 1 / fps;
1014
+ }
1015
+
921
1016
  private _onKeydown(e: KeyboardEvent): void {
922
1017
  // Don't intercept if the user is typing in an input
923
1018
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
@@ -964,6 +1059,21 @@ export class AvbridgePlayerElement extends HTMLElement {
964
1059
  this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
965
1060
  this._buildSettingsMenu();
966
1061
  break;
1062
+ case ",":
1063
+ // Frame-back (YouTube-style: , while paused steps back one frame)
1064
+ e.preventDefault();
1065
+ if (!this._video.paused) this._video.pause();
1066
+ this._video.currentTime = Math.max(0, this._video.currentTime - this._frameDuration());
1067
+ break;
1068
+ case ".":
1069
+ // Frame-forward (YouTube-style: . while paused steps forward one frame)
1070
+ e.preventDefault();
1071
+ if (!this._video.paused) this._video.pause();
1072
+ this._video.currentTime = Math.min(
1073
+ this._video.duration || 0,
1074
+ this._video.currentTime + this._frameDuration(),
1075
+ );
1076
+ break;
967
1077
  case "Escape":
968
1078
  if (this._settingsOpen) {
969
1079
  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
+ }