avbridge 2.9.0 → 2.10.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 +31 -0
- package/dist/{chunk-SN4WZE24.js → chunk-3GKM5DFM.js} +42 -5
- package/dist/chunk-3GKM5DFM.js.map +1 -0
- package/dist/{chunk-EY6DZEDT.cjs → chunk-NQULEIA3.cjs} +42 -5
- package/dist/chunk-NQULEIA3.cjs.map +1 -0
- package/dist/element-browser.js +40 -3
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +2 -2
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- 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 +230 -98
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +27 -0
- package/dist/player.d.ts +27 -0
- package/dist/player.js +230 -98
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +150 -75
- package/src/element/player-styles.ts +85 -35
- package/src/index.ts +1 -0
- package/src/strategies/fallback/audio-output.ts +29 -4
- package/src/strategies/fallback/index.ts +12 -0
- package/src/strategies/hybrid/index.ts +9 -0
- package/src/types.ts +25 -0
- package/dist/chunk-EY6DZEDT.cjs.map +0 -1
- package/dist/chunk-SN4WZE24.js.map +0 -1
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
|
|
|
@@ -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.
|
|
@@ -133,6 +135,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
133
135
|
this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement;
|
|
134
136
|
this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement;
|
|
135
137
|
this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement;
|
|
138
|
+
this._settingsScrim = shadow.querySelector(".avp-settings-scrim") as HTMLDivElement;
|
|
136
139
|
this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement;
|
|
137
140
|
// Badge removed from controls bar — strategy visible in Stats for Nerds.
|
|
138
141
|
// Spinner is rendered in shadow DOM, driven by CSS :host([data-state]).
|
|
@@ -199,7 +202,8 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
199
202
|
<button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
|
|
200
203
|
<button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
|
|
201
204
|
</div>
|
|
202
|
-
<div class="avp-settings
|
|
205
|
+
<div class="avp-settings-scrim"></div>
|
|
206
|
+
<div class="avp-settings" part="settings-menu"><div class="avp-settings-handle"></div></div>
|
|
203
207
|
</div>
|
|
204
208
|
</div>`;
|
|
205
209
|
}
|
|
@@ -285,6 +289,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
285
289
|
|
|
286
290
|
// Settings
|
|
287
291
|
on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
|
|
292
|
+
on(this._settingsScrim, "click", () => this._closeSettings());
|
|
288
293
|
|
|
289
294
|
// Fullscreen
|
|
290
295
|
on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
|
|
@@ -298,14 +303,9 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
298
303
|
on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
|
|
299
304
|
on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
|
|
300
305
|
|
|
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
|
|
306
|
+
// Dismiss settings sheet when clicking outside it. The scrim handles
|
|
307
|
+
// most of this (its own click handler calls _closeSettings), but we
|
|
308
|
+
// also catch clicks outside the player element entirely.
|
|
309
309
|
on(document, "click", (e) => {
|
|
310
310
|
if (this._settingsOpen && !this.contains(e.target as Node)) {
|
|
311
311
|
this._closeSettings();
|
|
@@ -535,100 +535,151 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
535
535
|
private _toggleSettings(): void {
|
|
536
536
|
this._settingsOpen = !this._settingsOpen;
|
|
537
537
|
this._settingsMenu.classList.toggle("open", this._settingsOpen);
|
|
538
|
-
|
|
538
|
+
this._settingsScrim.classList.toggle("open", this._settingsOpen);
|
|
539
|
+
if (this._settingsOpen) {
|
|
540
|
+
this._fitSettingsToPlayer();
|
|
541
|
+
this._showControls();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private _fitSettingsToPlayer(): void {
|
|
546
|
+
const container = this.shadowRoot?.querySelector(".avp") as HTMLElement | null;
|
|
547
|
+
if (!container) return;
|
|
548
|
+
const rect = container.getBoundingClientRect();
|
|
549
|
+
// Bottom sheet can use up to 70% of the player height, leaving room
|
|
550
|
+
// to see the video behind the scrim. Floor at 120px so it's always
|
|
551
|
+
// usable.
|
|
552
|
+
const maxH = Math.max(120, Math.floor(rect.height * 0.7));
|
|
553
|
+
this._settingsMenu.style.maxHeight = `${maxH}px`;
|
|
539
554
|
}
|
|
540
555
|
|
|
541
556
|
private _closeSettings(): void {
|
|
542
557
|
this._settingsOpen = false;
|
|
543
558
|
this._settingsMenu.classList.remove("open");
|
|
559
|
+
this._settingsScrim.classList.remove("open");
|
|
544
560
|
}
|
|
545
561
|
|
|
546
562
|
private _buildSettingsMenu(): void {
|
|
547
563
|
const sections: string[] = [];
|
|
548
564
|
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// Playback speed
|
|
565
|
+
// Helper: a row with an invisible native <select> layered on top of
|
|
566
|
+
// a styled label+value. Tapping opens the OS picker (escapes the
|
|
567
|
+
// shadow DOM — intentional for small players). The visible label
|
|
568
|
+
// updates on change.
|
|
569
|
+
const selectRow = (label: string, currentValue: string, options: string, selectAttrs: string): string =>
|
|
570
|
+
`<div class="avp-settings-section">` +
|
|
571
|
+
`<div class="avp-settings-header">` +
|
|
572
|
+
`<span class="avp-settings-header-label">${label}</span>` +
|
|
573
|
+
`<span class="avp-settings-header-value">${currentValue}</span>` +
|
|
574
|
+
`<select class="avp-settings-select" ${selectAttrs}>${options}</select>` +
|
|
575
|
+
`</div>` +
|
|
576
|
+
`</div>`;
|
|
577
|
+
|
|
578
|
+
// Speed
|
|
564
579
|
const currentRate = this._video.playbackRate ?? 1;
|
|
565
|
-
|
|
580
|
+
const speedValue = Math.abs(currentRate - 1) < 0.01 ? "Normal" : `${currentRate}x`;
|
|
581
|
+
let speedOpts = "";
|
|
566
582
|
for (const spd of PLAYBACK_SPEEDS) {
|
|
567
|
-
const
|
|
583
|
+
const sel = Math.abs(spd - currentRate) < 0.01 ? " selected" : "";
|
|
568
584
|
const label = spd === 1 ? "Normal" : `${spd}x`;
|
|
569
|
-
|
|
585
|
+
speedOpts += `<option value="${spd}"${sel}>${label}</option>`;
|
|
586
|
+
}
|
|
587
|
+
sections.push(selectRow("Speed", speedValue, speedOpts, `data-action="speed"`));
|
|
588
|
+
|
|
589
|
+
// Audio tracks
|
|
590
|
+
const audios = this._video.audioTracks ?? [];
|
|
591
|
+
if (audios.length > 1) {
|
|
592
|
+
let audioOpts = "";
|
|
593
|
+
for (const t of audios) {
|
|
594
|
+
audioOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
|
|
595
|
+
}
|
|
596
|
+
sections.push(selectRow("Audio", audios[0]?.language ?? "Track 1", audioOpts, `data-action="audio"`));
|
|
570
597
|
}
|
|
571
|
-
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
|
|
572
598
|
|
|
573
599
|
// Subtitle tracks
|
|
574
600
|
const subs = this._video.subtitleTracks ?? [];
|
|
575
601
|
if (subs.length > 0) {
|
|
576
|
-
let
|
|
602
|
+
let subOpts = `<option value="-1" selected>Off</option>`;
|
|
577
603
|
for (const t of subs) {
|
|
578
|
-
|
|
604
|
+
subOpts += `<option value="${t.id}">${t.language ?? `Track ${t.id}`}</option>`;
|
|
579
605
|
}
|
|
580
|
-
sections.push(
|
|
606
|
+
sections.push(selectRow("Subtitles", "Off", subOpts, `data-action="subtitle"`));
|
|
581
607
|
}
|
|
582
608
|
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
609
|
+
// Fit mode — opt-in via the `show-fit` attribute
|
|
610
|
+
if (this.hasAttribute("show-fit")) {
|
|
611
|
+
const currentFit = (this._video.fit ?? "contain") as FitMode;
|
|
612
|
+
const fitValue = currentFit[0].toUpperCase() + currentFit.slice(1);
|
|
613
|
+
let fitOpts = "";
|
|
614
|
+
for (const mode of FIT_MODES) {
|
|
615
|
+
const sel = mode === currentFit ? " selected" : "";
|
|
616
|
+
const label = mode[0].toUpperCase() + mode.slice(1);
|
|
617
|
+
fitOpts += `<option value="${mode}"${sel}>${label}</option>`;
|
|
589
618
|
}
|
|
590
|
-
sections.push(
|
|
619
|
+
sections.push(selectRow("Fit", fitValue, fitOpts, `data-action="fit"`));
|
|
591
620
|
}
|
|
592
621
|
|
|
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
|
-
});
|
|
622
|
+
// Consumer-added sections
|
|
623
|
+
for (const cfg of this._customSections) {
|
|
624
|
+
const activeItem = cfg.items.find((i) => i.active);
|
|
625
|
+
let customOpts = "";
|
|
626
|
+
for (const item of cfg.items) {
|
|
627
|
+
const sel = item.active ? " selected" : "";
|
|
628
|
+
customOpts += `<option value="${item.id}"${sel}>${item.label}</option>`;
|
|
629
|
+
}
|
|
630
|
+
sections.push(selectRow(cfg.label, activeItem?.label ?? "", customOpts, `data-action="custom" data-custom-id="${cfg.id}"`));
|
|
606
631
|
}
|
|
607
|
-
|
|
608
|
-
|
|
632
|
+
|
|
633
|
+
// Stats for nerds — toggle row (no select)
|
|
634
|
+
sections.push(
|
|
635
|
+
`<div class="avp-settings-section">` +
|
|
636
|
+
`<div class="avp-settings-header avp-settings-toggle" data-stats>` +
|
|
637
|
+
`<span class="avp-settings-header-label">Stats for Nerds</span>` +
|
|
638
|
+
`</div>` +
|
|
639
|
+
`</div>`,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// Rebuild sheet content (preserve the handle).
|
|
643
|
+
const handle = this._settingsMenu.querySelector(".avp-settings-handle");
|
|
644
|
+
this._settingsMenu.innerHTML = "";
|
|
645
|
+
if (handle) this._settingsMenu.appendChild(handle);
|
|
646
|
+
else this._settingsMenu.insertAdjacentHTML("afterbegin", `<div class="avp-settings-handle"></div>`);
|
|
647
|
+
this._settingsMenu.insertAdjacentHTML("beforeend", sections.join(""));
|
|
648
|
+
|
|
649
|
+
// ── <select> change handlers ──
|
|
650
|
+
for (const sel of this._settingsMenu.querySelectorAll<HTMLSelectElement>(".avp-settings-select")) {
|
|
651
|
+
sel.addEventListener("change", (e) => {
|
|
609
652
|
e.stopPropagation();
|
|
610
|
-
|
|
653
|
+
const action = sel.dataset.action;
|
|
654
|
+
const val = sel.value;
|
|
655
|
+
switch (action) {
|
|
656
|
+
case "speed":
|
|
657
|
+
this._video.playbackRate = Number(val);
|
|
658
|
+
break;
|
|
659
|
+
case "audio":
|
|
660
|
+
void this._video.setAudioTrack(Number(val));
|
|
661
|
+
break;
|
|
662
|
+
case "subtitle":
|
|
663
|
+
void this._video.setSubtitleTrack(Number(val) >= 0 ? Number(val) : null);
|
|
664
|
+
break;
|
|
665
|
+
case "fit":
|
|
666
|
+
this.setAttribute("fit", val);
|
|
667
|
+
break;
|
|
668
|
+
case "custom": {
|
|
669
|
+
const cfgId = sel.dataset.customId!;
|
|
670
|
+
const cfg = this._customSections.find((s) => s.id === cfgId);
|
|
671
|
+
cfg?.onSelect(val);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
611
675
|
this._buildSettingsMenu();
|
|
612
676
|
});
|
|
613
677
|
}
|
|
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) => {
|
|
678
|
+
|
|
679
|
+
// Stats toggle (no select — just a clickable row)
|
|
680
|
+
const statsRow = this._settingsMenu.querySelector("[data-stats]");
|
|
681
|
+
if (statsRow) {
|
|
682
|
+
statsRow.addEventListener("click", (e) => {
|
|
632
683
|
e.stopPropagation();
|
|
633
684
|
this._toggleStats();
|
|
634
685
|
this._closeSettings();
|
|
@@ -750,10 +801,17 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
750
801
|
}
|
|
751
802
|
|
|
752
803
|
private _onContainerClick(e: MouseEvent): void {
|
|
753
|
-
// Ignore clicks on controls
|
|
804
|
+
// Ignore clicks on controls and slotted content
|
|
754
805
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
755
806
|
if (this._isSlottedContentEvent(e)) return;
|
|
756
807
|
|
|
808
|
+
// If the bottom sheet is open, any click outside it dismisses
|
|
809
|
+
// instead of toggling play/pause.
|
|
810
|
+
if (this._settingsOpen) {
|
|
811
|
+
this._closeSettings();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
757
815
|
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
758
816
|
// The browser fires a synthetic click after touchend — skip it.
|
|
759
817
|
if (this._lastPointerTypeWasTouch) {
|
|
@@ -799,6 +857,12 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
799
857
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
800
858
|
if (this._isSlottedContentEvent(e)) return;
|
|
801
859
|
|
|
860
|
+
// If the bottom sheet is open, dismiss it on any touch outside.
|
|
861
|
+
if (this._settingsOpen) {
|
|
862
|
+
this._closeSettings();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
802
866
|
// Double-tap detection
|
|
803
867
|
const now = Date.now();
|
|
804
868
|
if (now - this._lastTapTime < 300) {
|
|
@@ -991,6 +1055,17 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
991
1055
|
return this._video.destroy();
|
|
992
1056
|
}
|
|
993
1057
|
async setAudioTrack(id: number): Promise<void> { return this._video.setAudioTrack(id); }
|
|
1058
|
+
|
|
1059
|
+
addSettingsSection(config: SettingsSectionConfig): void {
|
|
1060
|
+
this._customSections = this._customSections.filter((s) => s.id !== config.id);
|
|
1061
|
+
this._customSections.push(config);
|
|
1062
|
+
this._buildSettingsMenu();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
removeSettingsSection(id: string): void {
|
|
1066
|
+
this._customSections = this._customSections.filter((s) => s.id !== id);
|
|
1067
|
+
this._buildSettingsMenu();
|
|
1068
|
+
}
|
|
994
1069
|
async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
|
|
995
1070
|
getDiagnostics(): unknown { return this._video.getDiagnostics(); }
|
|
996
1071
|
canPlayType(mime: string): string { return this._video.canPlayType(mime); }
|
|
@@ -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
|
|
@@ -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/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
|
+
}
|