avbridge 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/{chunk-EY6DZEDT.cjs → chunk-37UOSAVI.cjs} +55 -10
- 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-SN4WZE24.js → chunk-IHNHHEA2.js} +51 -6
- 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 +63 -4
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +18 -5
- 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 +17 -4
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +10 -10
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{player-DEcidWk6.d.cts → player-DDdNVFDv.d.cts} +23 -1
- package/dist/{player-DEcidWk6.d.ts → player-DDdNVFDv.d.ts} +23 -1
- package/dist/player.cjs +329 -109
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +42 -0
- package/dist/player.d.ts +42 -0
- package/dist/player.js +325 -105
- 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 +235 -78
- package/src/element/avbridge-subtitles.ts +273 -0
- package/src/element/avbridge-video.ts +21 -1
- package/src/element/player-styles.ts +85 -35
- package/src/index.ts +1 -0
- package/src/strategies/fallback/audio-output.ts +39 -4
- package/src/strategies/fallback/index.ts +12 -0
- package/src/strategies/hybrid/index.ts +9 -0
- package/src/subtitles/index.ts +2 -0
- package/src/types.ts +25 -0
- package/dist/chunk-5KVLE6YI.js.map +0 -1
- package/dist/chunk-EY6DZEDT.cjs.map +0 -1
- package/dist/chunk-S4WAZC2T.cjs.map +0 -1
- package/dist/chunk-SN4WZE24.js.map +0 -1
- package/dist/subtitles-4T74JRGT.js +0 -4
- package/dist/subtitles-QUH4LPI4.cjs +0 -29
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<avbridge-subtitles>` — scrollable subtitle timeline panel.
|
|
3
|
+
*
|
|
4
|
+
* Connects to an `<avbridge-player>` or `<avbridge-video>` via the `for`
|
|
5
|
+
* attribute (points to the player's `id`) or auto-detects a sibling.
|
|
6
|
+
* Reads TextTrack cues from the player's inner `<video>`, renders them
|
|
7
|
+
* as a timestamped list, highlights the active cue, and seeks on click.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <avbridge-player id="player">...</avbridge-player>
|
|
11
|
+
* <avbridge-subtitles for="player"></avbridge-subtitles>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HTMLElementCtor: typeof HTMLElement =
|
|
15
|
+
typeof HTMLElement !== "undefined"
|
|
16
|
+
? HTMLElement
|
|
17
|
+
: (class {} as unknown as typeof HTMLElement);
|
|
18
|
+
|
|
19
|
+
const STYLES = `
|
|
20
|
+
:host {
|
|
21
|
+
display: block;
|
|
22
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
color: #eee;
|
|
25
|
+
background: #1a1a1a;
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
overscroll-behavior: contain;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.avs-empty {
|
|
31
|
+
padding: 16px;
|
|
32
|
+
opacity: 0.5;
|
|
33
|
+
text-align: center;
|
|
34
|
+
font-size: 13px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.avs-cue {
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 12px;
|
|
40
|
+
padding: 8px 12px;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
43
|
+
transition: background 0.1s;
|
|
44
|
+
align-items: flex-start;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.avs-cue:hover {
|
|
48
|
+
background: rgba(255, 255, 255, 0.06);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.avs-cue.active {
|
|
52
|
+
background: rgba(62, 166, 255, 0.12);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.avs-time {
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
font-size: 12px;
|
|
58
|
+
font-variant-numeric: tabular-nums;
|
|
59
|
+
opacity: 0.5;
|
|
60
|
+
min-width: 48px;
|
|
61
|
+
padding-top: 1px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.avs-cue.active .avs-time {
|
|
65
|
+
opacity: 0.8;
|
|
66
|
+
color: #3ea6ff;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.avs-text {
|
|
70
|
+
flex: 1;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
line-height: 1.4;
|
|
73
|
+
word-break: break-word;
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
interface CueEntry {
|
|
78
|
+
start: number;
|
|
79
|
+
end: number;
|
|
80
|
+
text: string;
|
|
81
|
+
el: HTMLDivElement;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class AvbridgeSubtitlesElement extends HTMLElementCtor {
|
|
85
|
+
static readonly observedAttributes = ["for"];
|
|
86
|
+
|
|
87
|
+
private _player: HTMLElement | null = null;
|
|
88
|
+
private _cues: CueEntry[] = [];
|
|
89
|
+
private _tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
90
|
+
private _activeCueIndex = -1;
|
|
91
|
+
private _trackChangeListener: (() => void) | null = null;
|
|
92
|
+
|
|
93
|
+
constructor() {
|
|
94
|
+
super();
|
|
95
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
96
|
+
shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No subtitles loaded</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
connectedCallback(): void {
|
|
100
|
+
this._connectPlayer();
|
|
101
|
+
this._startTick();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
disconnectedCallback(): void {
|
|
105
|
+
this._stopTick();
|
|
106
|
+
this._disconnectPlayer();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
attributeChangedCallback(name: string): void {
|
|
110
|
+
if (name === "for") {
|
|
111
|
+
this._disconnectPlayer();
|
|
112
|
+
this._connectPlayer();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Player connection ──────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
private _connectPlayer(): void {
|
|
119
|
+
const forId = this.getAttribute("for");
|
|
120
|
+
if (forId) {
|
|
121
|
+
this._player = document.getElementById(forId);
|
|
122
|
+
} else {
|
|
123
|
+
// Auto-detect sibling avbridge-player or avbridge-video
|
|
124
|
+
this._player =
|
|
125
|
+
this.parentElement?.querySelector("avbridge-player") ??
|
|
126
|
+
this.parentElement?.querySelector("avbridge-video") ??
|
|
127
|
+
null;
|
|
128
|
+
}
|
|
129
|
+
if (!this._player) return;
|
|
130
|
+
|
|
131
|
+
// Listen for trackschange to rebuild the cue list when subtitles
|
|
132
|
+
// are added/removed dynamically.
|
|
133
|
+
this._trackChangeListener = () => this._rebuildCues();
|
|
134
|
+
this._player.addEventListener("trackschange", this._trackChangeListener);
|
|
135
|
+
|
|
136
|
+
// Initial build (subtitle may already be loaded).
|
|
137
|
+
// Defer so the player has time to bootstrap.
|
|
138
|
+
requestAnimationFrame(() => this._rebuildCues());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private _disconnectPlayer(): void {
|
|
142
|
+
if (this._player && this._trackChangeListener) {
|
|
143
|
+
this._player.removeEventListener("trackschange", this._trackChangeListener);
|
|
144
|
+
}
|
|
145
|
+
this._player = null;
|
|
146
|
+
this._trackChangeListener = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private _getVideoElement(): HTMLVideoElement | null {
|
|
150
|
+
if (!this._player) return null;
|
|
151
|
+
return (this._player as unknown as { videoElement?: HTMLVideoElement }).videoElement ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Cue list ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
private _rebuildCues(): void {
|
|
157
|
+
const video = this._getVideoElement();
|
|
158
|
+
const shadow = this.shadowRoot!;
|
|
159
|
+
this._cues = [];
|
|
160
|
+
this._activeCueIndex = -1;
|
|
161
|
+
|
|
162
|
+
if (!video) {
|
|
163
|
+
shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No player connected</div>`;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Find the first subtitle/caption track with cues.
|
|
168
|
+
let track: TextTrack | null = null;
|
|
169
|
+
for (let i = 0; i < video.textTracks.length; i++) {
|
|
170
|
+
const t = video.textTracks[i];
|
|
171
|
+
if ((t.kind === "subtitles" || t.kind === "captions") && t.cues && t.cues.length > 0) {
|
|
172
|
+
track = t;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!track || !track.cues || track.cues.length === 0) {
|
|
178
|
+
shadow.innerHTML = `<style>${STYLES}</style><div class="avs-empty">No subtitle cues available</div>`;
|
|
179
|
+
// Retry shortly — cues may load async.
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
if (this._cues.length === 0 && this.isConnected) this._rebuildCues();
|
|
182
|
+
}, 1000);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Build the list.
|
|
187
|
+
const container = document.createElement("div");
|
|
188
|
+
for (let i = 0; i < track.cues.length; i++) {
|
|
189
|
+
const cue = track.cues[i] as VTTCue;
|
|
190
|
+
const el = document.createElement("div");
|
|
191
|
+
el.className = "avs-cue";
|
|
192
|
+
|
|
193
|
+
const timeEl = document.createElement("span");
|
|
194
|
+
timeEl.className = "avs-time";
|
|
195
|
+
timeEl.textContent = formatTime(cue.startTime);
|
|
196
|
+
|
|
197
|
+
const textEl = document.createElement("span");
|
|
198
|
+
textEl.className = "avs-text";
|
|
199
|
+
textEl.textContent = cue.text.replace(/<[^>]+>/g, "");
|
|
200
|
+
|
|
201
|
+
el.appendChild(timeEl);
|
|
202
|
+
el.appendChild(textEl);
|
|
203
|
+
|
|
204
|
+
const startTime = cue.startTime;
|
|
205
|
+
el.addEventListener("click", () => {
|
|
206
|
+
if (this._player) {
|
|
207
|
+
(this._player as unknown as { currentTime: number }).currentTime = startTime;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
container.appendChild(el);
|
|
212
|
+
this._cues.push({ start: cue.startTime, end: cue.endTime, text: cue.text, el });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
shadow.innerHTML = `<style>${STYLES}</style>`;
|
|
216
|
+
shadow.appendChild(container);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Active cue tracking ────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
private _startTick(): void {
|
|
222
|
+
if (this._tickTimer) return;
|
|
223
|
+
this._tickTimer = setInterval(() => this._tick(), 250);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private _stopTick(): void {
|
|
227
|
+
if (this._tickTimer) {
|
|
228
|
+
clearInterval(this._tickTimer);
|
|
229
|
+
this._tickTimer = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private _tick(): void {
|
|
234
|
+
if (this._cues.length === 0 || !this._player) return;
|
|
235
|
+
const currentTime = (this._player as unknown as { currentTime: number }).currentTime ?? 0;
|
|
236
|
+
|
|
237
|
+
let newActive = -1;
|
|
238
|
+
for (let i = 0; i < this._cues.length; i++) {
|
|
239
|
+
const c = this._cues[i];
|
|
240
|
+
if (currentTime >= c.start && currentTime <= c.end) {
|
|
241
|
+
newActive = i;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (newActive === this._activeCueIndex) return;
|
|
247
|
+
|
|
248
|
+
// Remove previous highlight.
|
|
249
|
+
if (this._activeCueIndex >= 0 && this._activeCueIndex < this._cues.length) {
|
|
250
|
+
this._cues[this._activeCueIndex].el.classList.remove("active");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this._activeCueIndex = newActive;
|
|
254
|
+
|
|
255
|
+
// Apply new highlight + scroll into view.
|
|
256
|
+
if (newActive >= 0) {
|
|
257
|
+
const cue = this._cues[newActive];
|
|
258
|
+
cue.el.classList.add("active");
|
|
259
|
+
cue.el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatTime(sec: number): string {
|
|
265
|
+
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
|
266
|
+
const total = Math.floor(sec);
|
|
267
|
+
const h = Math.floor(total / 3600);
|
|
268
|
+
const m = Math.floor((total % 3600) / 60);
|
|
269
|
+
const s = total % 60;
|
|
270
|
+
const mm = String(m).padStart(h > 0 ? 2 : 1, "0");
|
|
271
|
+
const ss = String(s).padStart(2, "0");
|
|
272
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
273
|
+
}
|
|
@@ -856,15 +856,35 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
856
856
|
sidecarUrl: subtitle.url,
|
|
857
857
|
};
|
|
858
858
|
this._subtitleTracks.push(track);
|
|
859
|
+
// eslint-disable-next-line no-console
|
|
860
|
+
console.log(`[avbridge:subs] addSubtitle id=${track.id} format=${format} lang=${subtitle.language ?? "?"}`);
|
|
859
861
|
await attachSubtitleTracks(
|
|
860
862
|
this._videoEl,
|
|
861
863
|
this._subtitleTracks,
|
|
862
864
|
undefined,
|
|
863
865
|
(err, t) => {
|
|
864
866
|
// eslint-disable-next-line no-console
|
|
865
|
-
console.warn(`[avbridge] subtitle ${t.id} failed: ${err.message}`);
|
|
867
|
+
console.warn(`[avbridge:subs] subtitle ${t.id} failed: ${err.message}`);
|
|
866
868
|
},
|
|
867
869
|
);
|
|
870
|
+
// Enable the newly-added track so it renders immediately. On native
|
|
871
|
+
// strategy the <video>'s textTrack must be mode="showing"; on canvas
|
|
872
|
+
// strategies the renderer's watchTextTracks picks it up from the
|
|
873
|
+
// hidden-mode textTracks.
|
|
874
|
+
const textTracks = this._videoEl.textTracks;
|
|
875
|
+
for (let i = 0; i < textTracks.length; i++) {
|
|
876
|
+
if (textTracks[i].label === (subtitle.language ?? `Subtitle ${track.id}`)) {
|
|
877
|
+
textTracks[i].mode = "showing";
|
|
878
|
+
// eslint-disable-next-line no-console
|
|
879
|
+
console.log(`[avbridge:subs] enabled textTrack[${i}] mode=showing`);
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// Notify the settings sheet so it rebuilds with the new track.
|
|
884
|
+
this._dispatch("trackschange", {
|
|
885
|
+
audioTracks: this._audioTracks,
|
|
886
|
+
subtitleTracks: this.subtitleTracks,
|
|
887
|
+
});
|
|
868
888
|
}
|
|
869
889
|
|
|
870
890
|
/**
|
|
@@ -34,7 +34,6 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
34
34
|
position: relative;
|
|
35
35
|
width: 100%;
|
|
36
36
|
height: 100%;
|
|
37
|
-
cursor: pointer;
|
|
38
37
|
-webkit-tap-highlight-color: transparent;
|
|
39
38
|
user-select: none;
|
|
40
39
|
}
|
|
@@ -484,62 +483,113 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
484
483
|
|
|
485
484
|
.avp-spacer { flex: 1; }
|
|
486
485
|
|
|
487
|
-
/* ── Settings
|
|
486
|
+
/* ── Settings bottom sheet ────────────────────────────────────────────── */
|
|
487
|
+
|
|
488
|
+
/* Scrim — semi-transparent overlay behind the sheet, above the video.
|
|
489
|
+
Tapping it dismisses the sheet. */
|
|
490
|
+
.avp-settings-scrim {
|
|
491
|
+
position: absolute;
|
|
492
|
+
inset: 0;
|
|
493
|
+
z-index: 9;
|
|
494
|
+
background: rgba(0, 0, 0, 0.4);
|
|
495
|
+
opacity: 0;
|
|
496
|
+
pointer-events: none;
|
|
497
|
+
transition: opacity 0.2s;
|
|
498
|
+
}
|
|
488
499
|
|
|
500
|
+
.avp-settings-scrim.open {
|
|
501
|
+
opacity: 1;
|
|
502
|
+
pointer-events: auto;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/* Sheet container — slides up from the bottom. Height is content-driven
|
|
506
|
+
up to a JS-measured max (set on open via style.maxHeight). */
|
|
489
507
|
.avp-settings {
|
|
490
508
|
position: absolute;
|
|
491
|
-
bottom:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
border-radius: 8px;
|
|
495
|
-
min-width: 220px;
|
|
496
|
-
/* Fit within the player: leave room for the controls bar (52px bottom)
|
|
497
|
-
and a small top margin (8px). On tall players this caps at 300px;
|
|
498
|
-
on short players it shrinks to whatever fits. */
|
|
499
|
-
max-height: min(300px, calc(100% - 52px - 8px));
|
|
500
|
-
overflow-y: auto;
|
|
501
|
-
display: none;
|
|
509
|
+
bottom: 0;
|
|
510
|
+
left: 0;
|
|
511
|
+
right: 0;
|
|
502
512
|
z-index: 10;
|
|
503
|
-
|
|
513
|
+
background: rgba(28, 28, 28, 0.97);
|
|
514
|
+
border-radius: 12px 12px 0 0;
|
|
515
|
+
overflow-y: auto;
|
|
516
|
+
overscroll-behavior: contain;
|
|
517
|
+
transform: translateY(100%);
|
|
518
|
+
transition: transform 0.2s ease-out;
|
|
519
|
+
max-height: 70%;
|
|
520
|
+
padding-bottom: 52px;
|
|
521
|
+
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
|
|
504
522
|
}
|
|
505
523
|
|
|
506
|
-
.avp-settings.open {
|
|
524
|
+
.avp-settings.open {
|
|
525
|
+
transform: translateY(0);
|
|
526
|
+
}
|
|
507
527
|
|
|
508
|
-
.
|
|
509
|
-
|
|
510
|
-
|
|
528
|
+
/* Drag handle indicator at top of sheet. */
|
|
529
|
+
.avp-settings-handle {
|
|
530
|
+
width: 36px;
|
|
531
|
+
height: 4px;
|
|
532
|
+
border-radius: 2px;
|
|
533
|
+
background: rgba(255, 255, 255, 0.3);
|
|
534
|
+
margin: 8px auto 4px;
|
|
511
535
|
}
|
|
512
536
|
|
|
513
|
-
|
|
537
|
+
/* ── Accordion sections ──────────────────────────────────────────────── */
|
|
514
538
|
|
|
515
|
-
.avp-settings-
|
|
516
|
-
|
|
517
|
-
font-size: 11px;
|
|
518
|
-
text-transform: uppercase;
|
|
519
|
-
letter-spacing: 0.5px;
|
|
520
|
-
opacity: 0.5;
|
|
539
|
+
.avp-settings-section {
|
|
540
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
521
541
|
}
|
|
522
542
|
|
|
523
|
-
.avp-settings-
|
|
543
|
+
.avp-settings-section:last-child { border-bottom: none; }
|
|
544
|
+
|
|
545
|
+
/* Section header — clickable row showing label + current value. */
|
|
546
|
+
.avp-settings-header {
|
|
547
|
+
position: relative;
|
|
524
548
|
display: flex;
|
|
525
549
|
align-items: center;
|
|
526
|
-
|
|
527
|
-
|
|
550
|
+
justify-content: space-between;
|
|
551
|
+
padding: 12px 16px;
|
|
528
552
|
cursor: pointer;
|
|
553
|
+
font-size: 14px;
|
|
529
554
|
transition: background 0.1s;
|
|
530
555
|
}
|
|
531
556
|
|
|
532
|
-
.avp-settings-
|
|
557
|
+
.avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
|
|
533
558
|
|
|
534
|
-
.avp-settings-
|
|
535
|
-
|
|
559
|
+
.avp-settings-header-label {
|
|
560
|
+
display: flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
gap: 8px;
|
|
563
|
+
font-weight: 500;
|
|
536
564
|
}
|
|
537
565
|
|
|
538
|
-
.avp-settings-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
font-
|
|
566
|
+
.avp-settings-header-value {
|
|
567
|
+
margin-left: auto;
|
|
568
|
+
opacity: 0.6;
|
|
569
|
+
font-size: 13px;
|
|
570
|
+
text-align: right;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* Invisible native <select> layered over the value portion of the row.
|
|
574
|
+
Covers from the value text to the right edge so tapping the value
|
|
575
|
+
opens the OS picker. The label side remains inert. */
|
|
576
|
+
.avp-settings-select {
|
|
577
|
+
position: absolute;
|
|
578
|
+
top: 0;
|
|
579
|
+
right: 0;
|
|
580
|
+
bottom: 0;
|
|
581
|
+
width: 50%;
|
|
582
|
+
opacity: 0;
|
|
583
|
+
cursor: pointer;
|
|
584
|
+
font-size: 16px;
|
|
585
|
+
direction: rtl;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* Toggle-style rows (Stats for Nerds) — no select, just clickable. */
|
|
589
|
+
.avp-settings-toggle {
|
|
590
|
+
cursor: pointer;
|
|
542
591
|
}
|
|
592
|
+
.avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
|
|
543
593
|
|
|
544
594
|
/* ── Stats for nerds ──────────────────────────────────────────────────── */
|
|
545
595
|
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,10 @@ export class AudioOutput implements ClockSource {
|
|
|
80
80
|
private _volume = 1;
|
|
81
81
|
/** User-set muted flag. When true, gain is forced to 0. */
|
|
82
82
|
private _muted = false;
|
|
83
|
+
/** Playback rate. Scales the media clock and each AudioBufferSourceNode's
|
|
84
|
+
* playbackRate so audio pitches up/down accordingly (same as native
|
|
85
|
+
* <video>.playbackRate). Default 1. */
|
|
86
|
+
private _rate = 1;
|
|
83
87
|
|
|
84
88
|
constructor() {
|
|
85
89
|
this.ctx = new AudioContext();
|
|
@@ -107,6 +111,24 @@ export class AudioOutput implements ClockSource {
|
|
|
107
111
|
return this._muted;
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/** Set playback rate. Scales the media clock and pitches audio output
|
|
115
|
+
* (same as native <video>.playbackRate — speed without pitch correction).
|
|
116
|
+
* Rebases the anchor so the clock transition is seamless. */
|
|
117
|
+
setPlaybackRate(rate: number): void {
|
|
118
|
+
if (rate === this._rate) return;
|
|
119
|
+
// Rebase anchor at the current media time before changing rate,
|
|
120
|
+
// so the clock doesn't jump.
|
|
121
|
+
const t = this.now();
|
|
122
|
+
this.mediaTimeOfAnchor = t;
|
|
123
|
+
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
124
|
+
this.wallAnchorMs = performance.now();
|
|
125
|
+
this._rate = rate;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getPlaybackRate(): number {
|
|
129
|
+
return this._rate;
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
private applyGain(): void {
|
|
111
133
|
const target = this._muted ? 0 : this._volume;
|
|
112
134
|
try { this.gain.gain.value = target; } catch { /* ignore */ }
|
|
@@ -127,12 +149,12 @@ export class AudioOutput implements ClockSource {
|
|
|
127
149
|
now(): number {
|
|
128
150
|
if (this.noAudio) {
|
|
129
151
|
if (this.state === "playing") {
|
|
130
|
-
return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000;
|
|
152
|
+
return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000 * this._rate;
|
|
131
153
|
}
|
|
132
154
|
return this.mediaTimeOfAnchor;
|
|
133
155
|
}
|
|
134
156
|
if (this.state === "playing") {
|
|
135
|
-
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
|
|
157
|
+
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
136
158
|
}
|
|
137
159
|
return this.mediaTimeOfAnchor;
|
|
138
160
|
}
|
|
@@ -200,9 +222,12 @@ export class AudioOutput implements ClockSource {
|
|
|
200
222
|
const node = this.ctx.createBufferSource();
|
|
201
223
|
node.buffer = buffer;
|
|
202
224
|
node.connect(this.gain);
|
|
225
|
+
// Pitch the audio to match the playback rate (same as native <video>).
|
|
226
|
+
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
203
227
|
|
|
204
|
-
// Convert media time → ctx time using the anchor.
|
|
205
|
-
|
|
228
|
+
// Convert media time → ctx time using the anchor + rate. At rate=2,
|
|
229
|
+
// each second of media time occupies 0.5s of ctx time.
|
|
230
|
+
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
206
231
|
|
|
207
232
|
// When the decoder is slower than realtime, `ctxStart` falls into
|
|
208
233
|
// the past (ctx.currentTime has already passed it). Clamping each
|
|
@@ -256,6 +281,10 @@ export class AudioOutput implements ClockSource {
|
|
|
256
281
|
await this.ctx.resume();
|
|
257
282
|
}
|
|
258
283
|
|
|
284
|
+
// Reconnect the gain node — pause() disconnects it to cut off
|
|
285
|
+
// in-flight audio instantly. Safe to call even if already connected.
|
|
286
|
+
try { this.gain.connect(this.ctx.destination); } catch { /* ignore */ }
|
|
287
|
+
|
|
259
288
|
if (this.state === "paused") {
|
|
260
289
|
// Resume: media time should continue from where we paused. ctx.currentTime
|
|
261
290
|
// is preserved across suspend/resume, so re-anchoring it to "now" with
|
|
@@ -292,6 +321,12 @@ export class AudioOutput implements ClockSource {
|
|
|
292
321
|
this.mediaTimeOfAnchor = this.now();
|
|
293
322
|
this.state = "paused";
|
|
294
323
|
if (this.noAudio) return;
|
|
324
|
+
// Disconnect the gain node immediately so any in-flight scheduled
|
|
325
|
+
// buffers are silenced instantly. ctx.suspend() is async and
|
|
326
|
+
// already-started AudioBufferSourceNodes keep playing until the
|
|
327
|
+
// context actually suspends — without the disconnect, audio bleeds
|
|
328
|
+
// through for ~200ms after pause().
|
|
329
|
+
try { this.gain.disconnect(); } catch { /* ignore */ }
|
|
295
330
|
if (this.ctx.state === "running") {
|
|
296
331
|
await this.ctx.suspend();
|
|
297
332
|
}
|
|
@@ -128,6 +128,17 @@ export async function createFallbackSession(
|
|
|
128
128
|
get: () => ctx.duration ?? NaN,
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
|
+
// Playback rate — canvas strategies don't use the real <video>, so the
|
|
132
|
+
// native playbackRate property does nothing. Patch it to drive the
|
|
133
|
+
// AudioOutput clock speed + pitch.
|
|
134
|
+
Object.defineProperty(target, "playbackRate", {
|
|
135
|
+
configurable: true,
|
|
136
|
+
get: () => audio.getPlaybackRate(),
|
|
137
|
+
set: (v: number) => {
|
|
138
|
+
audio.setPlaybackRate(v);
|
|
139
|
+
target.dispatchEvent(new Event("ratechange"));
|
|
140
|
+
},
|
|
141
|
+
});
|
|
131
142
|
// Synthesize HTMLMediaElement parity surfaces that the canvas strategies
|
|
132
143
|
// can't otherwise answer truthfully (the inner <video> has no src, so
|
|
133
144
|
// its own readyState/seekable are zero/empty).
|
|
@@ -351,6 +362,7 @@ export async function createFallbackSession(
|
|
|
351
362
|
delete (target as unknown as Record<string, unknown>).muted;
|
|
352
363
|
delete (target as unknown as Record<string, unknown>).readyState;
|
|
353
364
|
delete (target as unknown as Record<string, unknown>).seekable;
|
|
365
|
+
delete (target as unknown as Record<string, unknown>).playbackRate;
|
|
354
366
|
} catch { /* ignore */ }
|
|
355
367
|
},
|
|
356
368
|
|
|
@@ -84,6 +84,14 @@ export async function createHybridSession(
|
|
|
84
84
|
get: () => ctx.duration ?? NaN,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
+
Object.defineProperty(target, "playbackRate", {
|
|
88
|
+
configurable: true,
|
|
89
|
+
get: () => audio.getPlaybackRate(),
|
|
90
|
+
set: (v: number) => {
|
|
91
|
+
audio.setPlaybackRate(v);
|
|
92
|
+
target.dispatchEvent(new Event("ratechange"));
|
|
93
|
+
},
|
|
94
|
+
});
|
|
87
95
|
// HTMLMediaElement parity surfaces — see fallback/index.ts for rationale.
|
|
88
96
|
Object.defineProperty(target, "readyState", {
|
|
89
97
|
configurable: true,
|
|
@@ -213,6 +221,7 @@ export async function createHybridSession(
|
|
|
213
221
|
delete (target as unknown as Record<string, unknown>).muted;
|
|
214
222
|
delete (target as unknown as Record<string, unknown>).readyState;
|
|
215
223
|
delete (target as unknown as Record<string, unknown>).seekable;
|
|
224
|
+
delete (target as unknown as Record<string, unknown>).playbackRate;
|
|
216
225
|
} catch { /* ignore */ }
|
|
217
226
|
},
|
|
218
227
|
|
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") {
|
package/src/types.ts
CHANGED
|
@@ -465,3 +465,28 @@ export interface ConvertResult {
|
|
|
465
465
|
*/
|
|
466
466
|
notes?: string[];
|
|
467
467
|
}
|
|
468
|
+
|
|
469
|
+
// ── Settings extensibility ──────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Configuration for a custom settings section added to `<avbridge-player>`
|
|
473
|
+
* via {@link addSettingsSection}. Sections render in the bottom-sheet
|
|
474
|
+
* settings panel alongside built-in sections (Speed, Audio, Subtitles,
|
|
475
|
+
* Fit, Stats for Nerds). The player owns rendering — consumers describe
|
|
476
|
+
* data; avbridge renders it in a consistent visual style.
|
|
477
|
+
*/
|
|
478
|
+
export interface SettingsSectionConfig {
|
|
479
|
+
/** Unique id for this section. Used to update/remove later. */
|
|
480
|
+
id: string;
|
|
481
|
+
/** Display label (e.g. "Quality", "Translate"). */
|
|
482
|
+
label: string;
|
|
483
|
+
/** Items to show when the section is expanded. */
|
|
484
|
+
items: Array<{
|
|
485
|
+
id: string;
|
|
486
|
+
label: string;
|
|
487
|
+
/** Mark the currently-selected item. */
|
|
488
|
+
active?: boolean;
|
|
489
|
+
}>;
|
|
490
|
+
/** Called when the user picks an item. */
|
|
491
|
+
onSelect(itemId: string): void;
|
|
492
|
+
}
|