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
|
@@ -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
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
ICON_FULLSCREEN, ICON_FULLSCREEN_EXIT,
|
|
24
24
|
ICON_REPLAY_10, ICON_FORWARD_10,
|
|
25
25
|
} from "./player-icons.js";
|
|
26
|
-
import type { AvbridgeVideoElementEventMap } from "../types.js";
|
|
26
|
+
import type { AvbridgeVideoElementEventMap, SettingsSectionConfig } from "../types.js";
|
|
27
27
|
|
|
28
28
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -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
|
|
|
@@ -87,6 +87,8 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
87
87
|
private _volumeInput!: HTMLInputElement;
|
|
88
88
|
private _settingsBtn!: HTMLButtonElement;
|
|
89
89
|
private _settingsMenu!: HTMLDivElement;
|
|
90
|
+
private _settingsScrim!: HTMLDivElement;
|
|
91
|
+
private _customSections: import("../types.js").SettingsSectionConfig[] = [];
|
|
90
92
|
private _fullscreenBtn!: HTMLButtonElement;
|
|
91
93
|
// Strategy badge removed — visible in Stats for Nerds instead.
|
|
92
94
|
// Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
|
|
@@ -99,6 +101,8 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
99
101
|
private _state: PlayerState = "idle";
|
|
100
102
|
private _controlsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
101
103
|
private _settingsOpen = false;
|
|
104
|
+
private _activeAudioTrackId: number | null = null;
|
|
105
|
+
private _activeSubtitleTrackId: number | null = null;
|
|
102
106
|
private _userSeeking = false;
|
|
103
107
|
private _holdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
104
108
|
private _holdSpeedActive = false;
|
|
@@ -133,6 +137,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
133
137
|
this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement;
|
|
134
138
|
this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement;
|
|
135
139
|
this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement;
|
|
140
|
+
this._settingsScrim = shadow.querySelector(".avp-settings-scrim") as HTMLDivElement;
|
|
136
141
|
this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement;
|
|
137
142
|
// Badge removed from controls bar — strategy visible in Stats for Nerds.
|
|
138
143
|
// Spinner is rendered in shadow DOM, driven by CSS :host([data-state]).
|
|
@@ -199,7 +204,8 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
199
204
|
<button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
|
|
200
205
|
<button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
|
|
201
206
|
</div>
|
|
202
|
-
<div class="avp-settings
|
|
207
|
+
<div class="avp-settings-scrim"></div>
|
|
208
|
+
<div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
|
|
203
209
|
</div>
|
|
204
210
|
</div>`;
|
|
205
211
|
}
|
|
@@ -285,6 +291,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
285
291
|
|
|
286
292
|
// Settings
|
|
287
293
|
on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
|
|
294
|
+
on(this._settingsScrim, "click", () => this._closeSettings());
|
|
288
295
|
|
|
289
296
|
// Fullscreen
|
|
290
297
|
on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
|
|
@@ -298,14 +305,9 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
298
305
|
on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
|
|
299
306
|
on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
|
|
300
307
|
|
|
301
|
-
// Dismiss settings
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
!(e.target as HTMLElement).closest?.(".avp-settings-btn, .avp-settings")) {
|
|
305
|
-
this._closeSettings();
|
|
306
|
-
}
|
|
307
|
-
});
|
|
308
|
-
// Also dismiss if user clicks outside the player element entirely
|
|
308
|
+
// Dismiss settings sheet when clicking outside it. The scrim handles
|
|
309
|
+
// most of this (its own click handler calls _closeSettings), but we
|
|
310
|
+
// also catch clicks outside the player element entirely.
|
|
309
311
|
on(document, "click", (e) => {
|
|
310
312
|
if (this._settingsOpen && !this.contains(e.target as Node)) {
|
|
311
313
|
this._closeSettings();
|
|
@@ -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);
|
|
@@ -535,100 +558,163 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
535
558
|
private _toggleSettings(): void {
|
|
536
559
|
this._settingsOpen = !this._settingsOpen;
|
|
537
560
|
this._settingsMenu.classList.toggle("open", this._settingsOpen);
|
|
538
|
-
|
|
561
|
+
this._settingsScrim.classList.toggle("open", this._settingsOpen);
|
|
562
|
+
if (this._settingsOpen) {
|
|
563
|
+
this._fitSettingsToPlayer();
|
|
564
|
+
this._showControls();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private _fitSettingsToPlayer(): void {
|
|
569
|
+
const container = this.shadowRoot?.querySelector(".avp") as HTMLElement | null;
|
|
570
|
+
if (!container) return;
|
|
571
|
+
const rect = container.getBoundingClientRect();
|
|
572
|
+
// Bottom sheet can use up to 70% of the player height, leaving room
|
|
573
|
+
// to see the video behind the scrim. Floor at 120px so it's always
|
|
574
|
+
// usable.
|
|
575
|
+
const maxH = Math.max(120, Math.floor(rect.height * 0.7));
|
|
576
|
+
this._settingsMenu.style.maxHeight = `${maxH}px`;
|
|
539
577
|
}
|
|
540
578
|
|
|
541
579
|
private _closeSettings(): void {
|
|
542
580
|
this._settingsOpen = false;
|
|
543
581
|
this._settingsMenu.classList.remove("open");
|
|
582
|
+
this._settingsScrim.classList.remove("open");
|
|
544
583
|
}
|
|
545
584
|
|
|
546
585
|
private _buildSettingsMenu(): void {
|
|
547
586
|
const sections: string[] = [];
|
|
548
587
|
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// Playback speed
|
|
588
|
+
// Helper: a row with an invisible native <select> layered on top of
|
|
589
|
+
// a styled label+value. Tapping opens the OS picker (escapes the
|
|
590
|
+
// shadow DOM — intentional for small players). The visible label
|
|
591
|
+
// updates on change.
|
|
592
|
+
const selectRow = (label: string, currentValue: string, options: string, selectAttrs: string): string =>
|
|
593
|
+
`<div class="avp-settings-section">` +
|
|
594
|
+
`<div class="avp-settings-header">` +
|
|
595
|
+
`<span class="avp-settings-header-label">${label}</span>` +
|
|
596
|
+
`<span class="avp-settings-header-value">${currentValue}</span>` +
|
|
597
|
+
`<select class="avp-settings-select" ${selectAttrs}>${options}</select>` +
|
|
598
|
+
`</div>` +
|
|
599
|
+
`</div>`;
|
|
600
|
+
|
|
601
|
+
// Speed
|
|
564
602
|
const currentRate = this._video.playbackRate ?? 1;
|
|
565
|
-
|
|
603
|
+
const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
|
|
604
|
+
let speedOpts = "";
|
|
566
605
|
for (const spd of PLAYBACK_SPEEDS) {
|
|
567
|
-
const
|
|
606
|
+
const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
|
|
568
607
|
const label = spd === 1 ? "Normal" : `${spd}x`;
|
|
569
|
-
|
|
608
|
+
speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
|
|
609
|
+
}
|
|
610
|
+
sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
|
|
611
|
+
|
|
612
|
+
// Audio tracks
|
|
613
|
+
const audios = this._video.audioTracks ?? [];
|
|
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}`;
|
|
618
|
+
let audioOpts = "";
|
|
619
|
+
for (const t of audios) {
|
|
620
|
+
const sel = t.id === activeAudioId ? " selected" : "";
|
|
621
|
+
audioOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
|
|
622
|
+
}
|
|
623
|
+
sections.push(selectRow("Audio", audioValue, audioOpts, `data-action="audio"`));
|
|
570
624
|
}
|
|
571
|
-
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
|
|
572
625
|
|
|
573
626
|
// Subtitle tracks
|
|
574
627
|
const subs = this._video.subtitleTracks ?? [];
|
|
575
628
|
if (subs.length > 0) {
|
|
576
|
-
|
|
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>`;
|
|
577
633
|
for (const t of subs) {
|
|
578
|
-
|
|
634
|
+
const sel = t.id === activeSubId ? " selected" : "";
|
|
635
|
+
subOpts += `<option value="${t.id}"${sel}>${t.language ?? `Track ${t.id}`}</option>`;
|
|
579
636
|
}
|
|
580
|
-
sections.push(
|
|
637
|
+
sections.push(selectRow("Subtitles", subValue, subOpts, `data-action="subtitle"`));
|
|
581
638
|
}
|
|
582
639
|
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
640
|
+
// Fit mode — opt-in via the `show-fit` attribute
|
|
641
|
+
if (this.hasAttribute("show-fit")) {
|
|
642
|
+
const currentFit = (this._video.fit ?? "contain") as FitMode;
|
|
643
|
+
const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
|
|
644
|
+
let fitOpts = "";
|
|
645
|
+
for (const mode of FIT_MODES) {
|
|
646
|
+
const sel = mode === currentFit ? " selected" : "";
|
|
647
|
+
const label = mode[0].toUpperCase() + mode.slice(1);
|
|
648
|
+
fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
|
|
589
649
|
}
|
|
590
|
-
sections.push(
|
|
650
|
+
sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
|
|
591
651
|
}
|
|
592
652
|
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const mode = (item as HTMLElement).dataset.fit as FitMode;
|
|
603
|
-
this.setAttribute("fit", mode);
|
|
604
|
-
this._buildSettingsMenu();
|
|
605
|
-
});
|
|
653
|
+
// Consumer-added sections
|
|
654
|
+
for (const cfg of this._customSections) {
|
|
655
|
+
const activeItem = cfg.items.find((i) => i.active);
|
|
656
|
+
let customOpts = "";
|
|
657
|
+
for (const item of cfg.items) {
|
|
658
|
+
const sel = item.active ? " selected" : "";
|
|
659
|
+
customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
|
|
660
|
+
}
|
|
661
|
+
sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
|
|
606
662
|
}
|
|
607
|
-
|
|
608
|
-
|
|
663
|
+
|
|
664
|
+
// Stats for nerds — toggle row (no select)
|
|
665
|
+
sections.push(
|
|
666
|
+
`<div class="avp-settings-section">` +
|
|
667
|
+
`<div class="avp-settings-header avp-settings-toggle" data-stats>` +
|
|
668
|
+
`<span class="avp-settings-header-label">Stats for Nerds</span>` +
|
|
669
|
+
`</div>` +
|
|
670
|
+
`</div>`,
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Rebuild sheet content (preserve the handle).
|
|
674
|
+
const handle = this._settingsMenu.querySelector(".avp-settings-handle");
|
|
675
|
+
this._settingsMenu.innerHTML = "";
|
|
676
|
+
if (handle) this._settingsMenu.appendChild(handle);
|
|
677
|
+
else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
|
|
678
|
+
this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
|
|
679
|
+
|
|
680
|
+
// ── <select> change handlers ──
|
|
681
|
+
for (const sel of this._settingsMenu.querySelectorAll<HTMLSelectElement>(".avp-settings-select")) {
|
|
682
|
+
sel.addEventListener("change", (e) => {
|
|
609
683
|
e.stopPropagation();
|
|
610
|
-
|
|
684
|
+
const action = sel.dataset.action;
|
|
685
|
+
const val = sel.value;
|
|
686
|
+
switch (action) {
|
|
687
|
+
case "speed":
|
|
688
|
+
this._video.playbackRate = Number(val);
|
|
689
|
+
break;
|
|
690
|
+
case "audio":
|
|
691
|
+
this._activeAudioTrackId = Number(val);
|
|
692
|
+
void this._video.setAudioTrack(Number(val));
|
|
693
|
+
break;
|
|
694
|
+
case "subtitle": {
|
|
695
|
+
const subId = Number(val);
|
|
696
|
+
this._activeSubtitleTrackId = subId >= 0 ? subId : null;
|
|
697
|
+
void this._video.setSubtitleTrack(subId >= 0 ? subId : null);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
case "fit":
|
|
701
|
+
this.setAttribute("fit", val);
|
|
702
|
+
break;
|
|
703
|
+
case "custom": {
|
|
704
|
+
const cfgId = sel.dataset.customId!;
|
|
705
|
+
const cfg = this._customSections.find((s) => s.id === cfgId);
|
|
706
|
+
cfg?.onSelect(val);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
611
710
|
this._buildSettingsMenu();
|
|
612
711
|
});
|
|
613
712
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
this._closeSettings();
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
|
|
623
|
-
item.addEventListener("click", (e) => {
|
|
624
|
-
e.stopPropagation();
|
|
625
|
-
void this._video.setAudioTrack(Number((item as HTMLElement).dataset.audio));
|
|
626
|
-
this._closeSettings();
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
const statsItem = this._settingsMenu.querySelector("[data-stats]");
|
|
630
|
-
if (statsItem) {
|
|
631
|
-
statsItem.addEventListener("click", (e) => {
|
|
713
|
+
|
|
714
|
+
// Stats toggle (no select — just a clickable row)
|
|
715
|
+
const statsRow = this._settingsMenu.querySelector("[data-stats]");
|
|
716
|
+
if (statsRow) {
|
|
717
|
+
statsRow.addEventListener("click", (e) => {
|
|
632
718
|
e.stopPropagation();
|
|
633
719
|
this._toggleStats();
|
|
634
720
|
this._closeSettings();
|
|
@@ -711,16 +797,28 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
711
797
|
this.showControls();
|
|
712
798
|
}
|
|
713
799
|
|
|
714
|
-
private _scheduleHide(durationMs
|
|
800
|
+
private _scheduleHide(durationMs?: number): void {
|
|
801
|
+
const ms = durationMs ?? this._getControlsTimeout();
|
|
715
802
|
if (this._controlsTimer) clearTimeout(this._controlsTimer);
|
|
716
803
|
if (this._state !== "playing" && this._state !== "buffering") return;
|
|
717
804
|
if (this._settingsOpen) return;
|
|
805
|
+
// A timeout of 0 or negative means "never hide" (controls always visible).
|
|
806
|
+
if (ms <= 0) return;
|
|
718
807
|
this._controlsTimer = setTimeout(() => {
|
|
719
808
|
if (this._state === "playing") {
|
|
720
809
|
this.setAttribute("data-controls-hidden", "");
|
|
721
810
|
this._toolbarTop.setAttribute("data-visible", "false");
|
|
722
811
|
}
|
|
723
|
-
},
|
|
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;
|
|
724
822
|
}
|
|
725
823
|
|
|
726
824
|
// Strategy is visible in Stats for Nerds, no badge in controls bar.
|
|
@@ -735,6 +833,9 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
735
833
|
|
|
736
834
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
737
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;
|
|
738
839
|
|
|
739
840
|
/** True if the event's composed path passes through consumer-slotted
|
|
740
841
|
* content (toolbar or content-overlay). Slotted content lives in the
|
|
@@ -750,10 +851,17 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
750
851
|
}
|
|
751
852
|
|
|
752
853
|
private _onContainerClick(e: MouseEvent): void {
|
|
753
|
-
// Ignore clicks on controls
|
|
854
|
+
// Ignore clicks on controls and slotted content
|
|
754
855
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
755
856
|
if (this._isSlottedContentEvent(e)) return;
|
|
756
857
|
|
|
858
|
+
// If the bottom sheet is open, any click outside it dismisses
|
|
859
|
+
// instead of toggling play/pause.
|
|
860
|
+
if (this._settingsOpen) {
|
|
861
|
+
this._closeSettings();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
757
865
|
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
758
866
|
// The browser fires a synthetic click after touchend — skip it.
|
|
759
867
|
if (this._lastPointerTypeWasTouch) {
|
|
@@ -772,6 +880,11 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
772
880
|
private _onContainerDblClick(e: MouseEvent): void {
|
|
773
881
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
|
|
774
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;
|
|
775
888
|
// Cancel the pending single-click play/pause
|
|
776
889
|
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
777
890
|
this._toggleFullscreen();
|
|
@@ -799,6 +912,12 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
799
912
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
800
913
|
if (this._isSlottedContentEvent(e)) return;
|
|
801
914
|
|
|
915
|
+
// If the bottom sheet is open, dismiss it on any touch outside.
|
|
916
|
+
if (this._settingsOpen) {
|
|
917
|
+
this._closeSettings();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
802
921
|
// Double-tap detection
|
|
803
922
|
const now = Date.now();
|
|
804
923
|
if (now - this._lastTapTime < 300) {
|
|
@@ -813,6 +932,10 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
813
932
|
} else {
|
|
814
933
|
this._toggleFullscreen();
|
|
815
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);
|
|
816
939
|
this._lastTapTime = 0;
|
|
817
940
|
return;
|
|
818
941
|
}
|
|
@@ -854,6 +977,14 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
854
977
|
|
|
855
978
|
// ── Keyboard shortcuts ─────────────────────────────────────────────────
|
|
856
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
|
+
|
|
857
988
|
private _onKeydown(e: KeyboardEvent): void {
|
|
858
989
|
// Don't intercept if the user is typing in an input
|
|
859
990
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
@@ -900,6 +1031,21 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
900
1031
|
this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
|
|
901
1032
|
this._buildSettingsMenu();
|
|
902
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;
|
|
903
1049
|
case "Escape":
|
|
904
1050
|
if (this._settingsOpen) {
|
|
905
1051
|
e.preventDefault();
|
|
@@ -991,6 +1137,17 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
991
1137
|
return this._video.destroy();
|
|
992
1138
|
}
|
|
993
1139
|
async setAudioTrack(id: number): Promise<void> { return this._video.setAudioTrack(id); }
|
|
1140
|
+
|
|
1141
|
+
addSettingsSection(config: SettingsSectionConfig): void {
|
|
1142
|
+
this._customSections = this._customSections.filter((s) => s.id !== config.id);
|
|
1143
|
+
this._customSections.push(config);
|
|
1144
|
+
this._buildSettingsMenu();
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
removeSettingsSection(id: string): void {
|
|
1148
|
+
this._customSections = this._customSections.filter((s) => s.id !== id);
|
|
1149
|
+
this._buildSettingsMenu();
|
|
1150
|
+
}
|
|
994
1151
|
async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
|
|
995
1152
|
getDiagnostics(): unknown { return this._video.getDiagnostics(); }
|
|
996
1153
|
canPlayType(mime: string): string { return this._video.canPlayType(mime); }
|