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.
- package/CHANGELOG.md +34 -0
- package/dist/{chunk-NQULEIA3.cjs → chunk-37UOSAVI.cjs} +15 -7
- package/dist/chunk-37UOSAVI.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-3GKM5DFM.js → chunk-IHNHHEA2.js} +11 -3
- package/dist/chunk-IHNHHEA2.js.map +1 -0
- package/dist/{chunk-S4WAZC2T.cjs → chunk-WRKO6Q42.cjs} +3 -2
- package/dist/chunk-WRKO6Q42.cjs.map +1 -0
- package/dist/element-browser.js +23 -1
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +18 -5
- package/dist/element.cjs.map +1 -1
- package/dist/element.js +17 -4
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +10 -10
- package/dist/index.js +2 -2
- package/dist/player.cjs +106 -18
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +15 -0
- package/dist/player.d.ts +15 -0
- package/dist/player.js +102 -14
- package/dist/player.js.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 +92 -10
- package/src/element/avbridge-subtitles.ts +273 -0
- package/src/element/avbridge-video.ts +21 -1
- package/src/strategies/fallback/audio-output.ts +10 -0
- package/src/subtitles/index.ts +2 -0
- package/dist/chunk-3GKM5DFM.js.map +0 -1
- package/dist/chunk-5KVLE6YI.js.map +0 -1
- package/dist/chunk-NQULEIA3.cjs.map +0 -1
- package/dist/chunk-S4WAZC2T.cjs.map +0 -1
- package/dist/subtitles-4T74JRGT.js +0 -4
- package/dist/subtitles-QUH4LPI4.cjs +0 -29
|
@@ -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;
|
|
@@ -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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
}
|
package/src/subtitles/index.ts
CHANGED
|
@@ -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") {
|