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.
- package/CHANGELOG.md +69 -0
- package/dist/{avi-B5CQYB7L.cjs → avi-EQE6AR75.cjs} +4 -4
- package/dist/{avi-2ILLBNPQ.cjs.map → avi-EQE6AR75.cjs.map} +1 -1
- package/dist/{avi-RWWPN2PR.js → avi-NNHH4AAA.js} +3 -3
- package/dist/{avi-JXU4GQL2.js.map → avi-NNHH4AAA.js.map} +1 -1
- package/dist/{avi-JXU4GQL2.js → avi-S7EY54YA.js} +3 -3
- package/dist/{avi-RWWPN2PR.js.map → avi-S7EY54YA.js.map} +1 -1
- package/dist/{avi-2ILLBNPQ.cjs → avi-Y3N325WZ.cjs} +4 -4
- package/dist/{avi-B5CQYB7L.cjs.map → avi-Y3N325WZ.cjs.map} +1 -1
- package/dist/{chunk-GYIJU44C.js → chunk-2LNXMGT6.js} +5 -5
- package/dist/{chunk-GYIJU44C.js.map → chunk-2LNXMGT6.js.map} +1 -1
- package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
- package/dist/chunk-3AI5WFFN.js.map +1 -0
- package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
- package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
- package/dist/{chunk-CL6UEUQF.js → chunk-5Y5BTB5D.js} +5 -5
- package/dist/{chunk-CL6UEUQF.js.map → chunk-5Y5BTB5D.js.map} +1 -1
- package/dist/{chunk-NQULEIA3.cjs → chunk-7EF4VTUS.cjs} +36 -28
- package/dist/chunk-7EF4VTUS.cjs.map +1 -0
- package/dist/{chunk-5KVLE6YI.js → chunk-EDDWAN2L.js} +3 -2
- package/dist/chunk-EDDWAN2L.js.map +1 -0
- package/dist/{chunk-OTFS7DC4.cjs → chunk-GJBNLPGI.cjs} +14 -14
- package/dist/{chunk-OTFS7DC4.cjs.map → chunk-GJBNLPGI.cjs.map} +1 -1
- package/dist/{chunk-BYGZN4Z5.cjs → chunk-HBHSUGNI.cjs} +5 -5
- package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-HBHSUGNI.cjs.map} +1 -1
- package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
- package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
- package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
- package/dist/chunk-WRKO6Q42.cjs.map +1 -0
- package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
- package/dist/chunk-YPZFGJV3.cjs.map +1 -0
- package/dist/{chunk-3GKM5DFM.js → chunk-Z26PXRUY.js} +18 -10
- package/dist/chunk-Z26PXRUY.js.map +1 -0
- package/dist/element-browser.js +65 -19
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +21 -8
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +20 -7
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +23 -23
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +10 -10
- package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
- package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
- package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
- package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
- package/dist/libav-http-reader-2S5HAHW4.js +3 -0
- package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
- package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
- package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
- package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
- package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
- package/dist/player.cjs +166 -33
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +36 -0
- package/dist/player.d.ts +36 -0
- package/dist/player.js +162 -29
- package/dist/player.js.map +1 -1
- package/dist/remux-7TA4FKTY.js +10 -0
- package/dist/{remux-56V7LDAD.js.map → remux-7TA4FKTY.js.map} +1 -1
- package/dist/remux-VPKCLHHM.cjs +35 -0
- package/dist/{remux-KUS5GIL6.cjs.map → remux-VPKCLHHM.cjs.map} +1 -1
- package/dist/subtitles-5H24MEBJ.js +4 -0
- package/dist/{subtitles-4T74JRGT.js.map → subtitles-5H24MEBJ.js.map} +1 -1
- package/dist/subtitles-HMVGWTU2.cjs +29 -0
- package/dist/{subtitles-QUH4LPI4.cjs.map → subtitles-HMVGWTU2.cjs.map} +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +128 -18
- package/src/element/avbridge-subtitles.ts +273 -0
- package/src/element/avbridge-video.ts +21 -1
- package/src/element/player-styles.ts +13 -1
- package/src/player.ts +3 -3
- package/src/strategies/fallback/audio-output.ts +10 -0
- package/src/subtitles/index.ts +2 -0
- package/src/types.ts +15 -0
- package/src/util/libav-http-reader.ts +58 -19
- package/dist/chunk-3GKM5DFM.js.map +0 -1
- package/dist/chunk-5KVLE6YI.js.map +0 -1
- package/dist/chunk-DCSOQH2N.js.map +0 -1
- package/dist/chunk-NQULEIA3.cjs.map +0 -1
- package/dist/chunk-S4WAZC2T.cjs.map +0 -1
- package/dist/chunk-Z33SBWL5.cjs.map +0 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
- package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
- package/dist/remux-56V7LDAD.js +0 -10
- package/dist/remux-KUS5GIL6.cjs +0 -35
- package/dist/subtitles-4T74JRGT.js +0 -4
- 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-
|
|
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-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-VPKCLHHM.cjs"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-
|
|
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-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"subtitles-HMVGWTU2.cjs"}
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
+
}
|