avbridge 2.2.1 → 2.5.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 +153 -1
- package/NOTICE.md +2 -2
- package/README.md +2 -3
- package/THIRD_PARTY_LICENSES.md +2 -2
- package/dist/avi-2JPBSHGA.js +183 -0
- package/dist/avi-2JPBSHGA.js.map +1 -0
- package/dist/avi-F6WZJK5T.cjs +185 -0
- package/dist/avi-F6WZJK5T.cjs.map +1 -0
- package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
- package/dist/avi-NJXAXUXK.js.map +1 -0
- package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
- package/dist/avi-W6L3BTWU.cjs.map +1 -0
- package/dist/chunk-2IJ66NTD.cjs +212 -0
- package/dist/chunk-2IJ66NTD.cjs.map +1 -0
- package/dist/{chunk-ILKDNBSE.js → chunk-2XW2O3YI.cjs} +55 -10
- package/dist/chunk-2XW2O3YI.cjs.map +1 -0
- package/dist/chunk-5KVLE6YI.js +167 -0
- package/dist/chunk-5KVLE6YI.js.map +1 -0
- package/dist/chunk-5YAWWKA3.js +18 -0
- package/dist/chunk-5YAWWKA3.js.map +1 -0
- package/dist/chunk-CPJLFFCC.js +189 -0
- package/dist/chunk-CPJLFFCC.js.map +1 -0
- package/dist/chunk-CPZ7PXAM.cjs +240 -0
- package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
- package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
- package/dist/chunk-DCSOQH2N.js.map +1 -0
- package/dist/{chunk-HZLQNKFN.cjs → chunk-E76AMWI4.js} +40 -15
- package/dist/chunk-E76AMWI4.js.map +1 -0
- package/dist/chunk-F3LQJKXK.cjs +20 -0
- package/dist/chunk-F3LQJKXK.cjs.map +1 -0
- package/dist/chunk-IAYKFGFG.js +200 -0
- package/dist/chunk-IAYKFGFG.js.map +1 -0
- package/dist/{chunk-DMWARSEF.js → chunk-KY2GPCT7.js} +788 -697
- package/dist/chunk-KY2GPCT7.js.map +1 -0
- package/dist/chunk-LUFA47FP.js +19 -0
- package/dist/chunk-LUFA47FP.js.map +1 -0
- package/dist/chunk-NNVOHKXJ.cjs +204 -0
- package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
- package/dist/chunk-Q2VUO52Z.cjs +374 -0
- package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
- package/dist/chunk-QDJLQR53.cjs +22 -0
- package/dist/chunk-QDJLQR53.cjs.map +1 -0
- package/dist/chunk-S4WAZC2T.cjs +173 -0
- package/dist/chunk-S4WAZC2T.cjs.map +1 -0
- package/dist/chunk-SMH6IOP2.js +368 -0
- package/dist/chunk-SMH6IOP2.js.map +1 -0
- package/dist/chunk-SR3MPV4D.js +237 -0
- package/dist/chunk-SR3MPV4D.js.map +1 -0
- package/dist/{chunk-UF2N5L63.cjs → chunk-TBW26OPP.cjs} +800 -710
- package/dist/chunk-TBW26OPP.cjs.map +1 -0
- package/dist/chunk-X2K3GIWE.js +235 -0
- package/dist/chunk-X2K3GIWE.js.map +1 -0
- package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
- package/dist/chunk-Z33SBWL5.cjs.map +1 -0
- package/dist/chunk-ZCUXHW55.cjs +242 -0
- package/dist/chunk-ZCUXHW55.cjs.map +1 -0
- package/dist/element-browser.js +1282 -503
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +59 -5
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +39 -1
- package/dist/element.d.ts +39 -1
- package/dist/element.js +58 -4
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +605 -327
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -4
- package/dist/index.d.ts +48 -4
- package/dist/index.js +528 -319
- package/dist/index.js.map +1 -1
- package/dist/libav-demux-H2GS46GH.cjs +27 -0
- package/dist/{libav-http-reader-NQJVY273.js.map → libav-demux-H2GS46GH.cjs.map} +1 -1
- package/dist/libav-demux-OWZ4T2YW.js +6 -0
- package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-demux-OWZ4T2YW.js.map} +1 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
- package/dist/libav-http-reader-AZLE7YFS.cjs.map +1 -0
- package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
- package/dist/libav-http-reader-WXG3Z7AI.js.map +1 -0
- package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
- package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
- package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
- package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
- package/dist/{player-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
- package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
- package/dist/player.cjs +5631 -0
- package/dist/player.cjs.map +1 -0
- package/dist/player.d.cts +699 -0
- package/dist/player.d.ts +699 -0
- package/dist/player.js +5629 -0
- package/dist/player.js.map +1 -0
- package/dist/remux-OBSMIENG.cjs +35 -0
- package/dist/remux-OBSMIENG.cjs.map +1 -0
- package/dist/remux-WBYIZBBX.js +10 -0
- package/dist/remux-WBYIZBBX.js.map +1 -0
- package/dist/source-4TZ6KMNV.js +4 -0
- package/dist/{source-FFZ7TW2B.js.map → source-4TZ6KMNV.js.map} +1 -1
- package/dist/source-7YLO6E7X.cjs +29 -0
- package/dist/{source-CN43EI7Z.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
- package/dist/source-MTX5ELUZ.js +4 -0
- package/dist/source-MTX5ELUZ.js.map +1 -0
- package/dist/source-VFLXLOCN.cjs +29 -0
- package/dist/source-VFLXLOCN.cjs.map +1 -0
- package/dist/subtitles-4T74JRGT.js +4 -0
- package/dist/subtitles-4T74JRGT.js.map +1 -0
- package/dist/subtitles-QUH4LPI4.cjs +29 -0
- package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
- package/dist/variant-routing-434STYAB.js +3 -0
- package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
- package/dist/variant-routing-HONNAA6R.cjs +12 -0
- package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
- package/package.json +9 -1
- package/src/classify/rules.ts +27 -5
- package/src/convert/remux.ts +9 -35
- package/src/convert/transcode-libav.ts +691 -0
- package/src/convert/transcode.ts +53 -12
- package/src/element/avbridge-player.ts +861 -0
- package/src/element/avbridge-video.ts +54 -0
- package/src/element/player-icons.ts +25 -0
- package/src/element/player-styles.ts +472 -0
- package/src/errors.ts +53 -0
- package/src/index.ts +23 -0
- package/src/player-element.ts +18 -0
- package/src/player.ts +118 -27
- package/src/plugins/builtin.ts +2 -2
- package/src/probe/avi.ts +4 -0
- package/src/probe/index.ts +40 -10
- package/src/strategies/fallback/audio-output.ts +31 -0
- package/src/strategies/fallback/decoder.ts +179 -175
- package/src/strategies/fallback/index.ts +48 -6
- package/src/strategies/fallback/libav-import.ts +9 -1
- package/src/strategies/fallback/variant-routing.ts +7 -13
- package/src/strategies/fallback/video-renderer.ts +231 -32
- package/src/strategies/hybrid/decoder.ts +219 -200
- package/src/strategies/hybrid/index.ts +48 -7
- package/src/strategies/native.ts +6 -3
- package/src/strategies/remux/index.ts +14 -2
- package/src/strategies/remux/mse.ts +12 -2
- package/src/strategies/remux/pipeline.ts +72 -12
- package/src/subtitles/index.ts +7 -3
- package/src/subtitles/render.ts +8 -0
- package/src/types.ts +53 -1
- package/src/util/libav-demux.ts +405 -0
- package/src/util/libav-http-reader.ts +5 -1
- package/src/util/source.ts +28 -8
- package/src/util/transport.ts +26 -0
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/dist/avi-6SJLWIWW.cjs.map +0 -1
- package/dist/avi-GCGM7OJI.js.map +0 -1
- package/dist/chunk-DMWARSEF.js.map +0 -1
- package/dist/chunk-HZLQNKFN.cjs.map +0 -1
- package/dist/chunk-ILKDNBSE.js.map +0 -1
- package/dist/chunk-J5MCMN3S.js +0 -27
- package/dist/chunk-J5MCMN3S.js.map +0 -1
- package/dist/chunk-L4NPOJ36.cjs.map +0 -1
- package/dist/chunk-NZU7W256.cjs +0 -29
- package/dist/chunk-NZU7W256.cjs.map +0 -1
- package/dist/chunk-UF2N5L63.cjs.map +0 -1
- package/dist/chunk-WD2ZNQA7.js.map +0 -1
- package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
- package/dist/libav-http-reader-NQJVY273.js +0 -3
- package/dist/source-CN43EI7Z.cjs +0 -28
- package/dist/source-FFZ7TW2B.js +0 -3
- package/dist/variant-routing-GOHB2RZN.cjs +0 -12
- package/dist/variant-routing-JOBWXYKD.js +0 -3
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<avbridge-player>` — YouTube-style controls element.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `<avbridge-video>` with a full player UI: play/pause, seek bar,
|
|
5
|
+
* volume, settings menu (speed, subtitles, audio tracks), fullscreen,
|
|
6
|
+
* keyboard shortcuts, touch gestures, and auto-hiding controls.
|
|
7
|
+
*
|
|
8
|
+
* All properties, methods, and events from `<avbridge-video>` are proxied
|
|
9
|
+
* through. Consumers interact with `<avbridge-player>` exclusively.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Import the class concretely and register — side-effect-only imports
|
|
13
|
+
// are tree-shaken by Rollup in production builds.
|
|
14
|
+
import { AvbridgeVideoElement } from "./avbridge-video.js";
|
|
15
|
+
if (typeof customElements !== "undefined" && !customElements.get("avbridge-video")) {
|
|
16
|
+
customElements.define("avbridge-video", AvbridgeVideoElement);
|
|
17
|
+
}
|
|
18
|
+
import { PLAYER_STYLES } from "./player-styles.js";
|
|
19
|
+
import {
|
|
20
|
+
ICON_PLAY, ICON_PAUSE,
|
|
21
|
+
ICON_VOLUME_UP, ICON_VOLUME_OFF,
|
|
22
|
+
ICON_SETTINGS,
|
|
23
|
+
ICON_FULLSCREEN, ICON_FULLSCREEN_EXIT,
|
|
24
|
+
ICON_REPLAY_10, ICON_FORWARD_10,
|
|
25
|
+
} from "./player-icons.js";
|
|
26
|
+
|
|
27
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function formatTime(sec: number): string {
|
|
30
|
+
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
|
31
|
+
const total = Math.floor(sec);
|
|
32
|
+
const h = Math.floor(total / 3600);
|
|
33
|
+
const m = Math.floor((total % 3600) / 60);
|
|
34
|
+
const s = total % 60;
|
|
35
|
+
const mm = String(m).padStart(h > 0 ? 2 : 1, "0");
|
|
36
|
+
const ss = String(s).padStart(2, "0");
|
|
37
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;
|
|
41
|
+
const CONTROLS_HIDE_MS = 3000;
|
|
42
|
+
|
|
43
|
+
type PlayerState = "idle" | "loading" | "playing" | "paused" | "buffering" | "ended" | "error";
|
|
44
|
+
|
|
45
|
+
// ── Forwarded events ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const FORWARDED_EVENTS = [
|
|
48
|
+
"ready", "error", "strategychange", "trackschange", "loadstart", "destroy",
|
|
49
|
+
"play", "playing", "pause", "seeking", "seeked", "volumechange",
|
|
50
|
+
"ratechange", "durationchange", "canplay", "canplaythrough",
|
|
51
|
+
"waiting", "stalled", "emptied", "resize",
|
|
52
|
+
"loadedmetadata", "loadeddata", "timeupdate", "ended", "progress",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
// ── Observed attributes ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const PROXY_ATTRIBUTES = [
|
|
58
|
+
"src", "autoplay", "muted", "loop", "preload", "poster",
|
|
59
|
+
"playsinline", "crossorigin", "disableremoteplayback", "preferstrategy",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
export class AvbridgePlayerElement extends HTMLElement {
|
|
65
|
+
static readonly observedAttributes = [...PROXY_ATTRIBUTES];
|
|
66
|
+
|
|
67
|
+
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
private _video!: AvbridgeVideoElement;
|
|
70
|
+
private _playBtn!: HTMLButtonElement;
|
|
71
|
+
private _overlayBtn!: HTMLButtonElement;
|
|
72
|
+
private _seekInput!: HTMLInputElement;
|
|
73
|
+
private _seekProgress!: HTMLDivElement;
|
|
74
|
+
private _seekBuffered!: HTMLDivElement;
|
|
75
|
+
private _seekThumb!: HTMLDivElement;
|
|
76
|
+
private _seekTooltip!: HTMLDivElement;
|
|
77
|
+
private _timeDisplay!: HTMLSpanElement;
|
|
78
|
+
private _volumeBtn!: HTMLButtonElement;
|
|
79
|
+
private _volumeInput!: HTMLInputElement;
|
|
80
|
+
private _settingsBtn!: HTMLButtonElement;
|
|
81
|
+
private _settingsMenu!: HTMLDivElement;
|
|
82
|
+
private _fullscreenBtn!: HTMLButtonElement;
|
|
83
|
+
// Strategy badge removed — visible in Stats for Nerds instead.
|
|
84
|
+
// Spinner is rendered but driven entirely by CSS :host([data-state]) selectors.
|
|
85
|
+
private _speedIndicator!: HTMLDivElement;
|
|
86
|
+
private _rippleLeft!: HTMLDivElement;
|
|
87
|
+
private _rippleRight!: HTMLDivElement;
|
|
88
|
+
|
|
89
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
private _state: PlayerState = "idle";
|
|
92
|
+
private _controlsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
93
|
+
private _settingsOpen = false;
|
|
94
|
+
private _userSeeking = false;
|
|
95
|
+
private _holdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
96
|
+
private _holdSpeedActive = false;
|
|
97
|
+
private _savedPlaybackRate = 1;
|
|
98
|
+
private _lastTapTime = 0;
|
|
99
|
+
private _tapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
private _statsOpen = false;
|
|
101
|
+
private _statsEl!: HTMLDivElement;
|
|
102
|
+
private _statsInterval: ReturnType<typeof setInterval> | null = null;
|
|
103
|
+
private _eventCleanup: (() => void)[] = [];
|
|
104
|
+
|
|
105
|
+
// ── Constructor ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
constructor() {
|
|
108
|
+
super();
|
|
109
|
+
const shadow = this.attachShadow({ mode: "open" });
|
|
110
|
+
shadow.innerHTML = `<style>${PLAYER_STYLES}</style>${this._template()}`;
|
|
111
|
+
|
|
112
|
+
// Grab refs
|
|
113
|
+
this._video = shadow.querySelector("avbridge-video") as AvbridgeVideoElement;
|
|
114
|
+
this._playBtn = shadow.querySelector(".avp-play") as HTMLButtonElement;
|
|
115
|
+
this._overlayBtn = shadow.querySelector(".avp-overlay-btn") as HTMLButtonElement;
|
|
116
|
+
this._seekInput = shadow.querySelector(".avp-seek-input") as HTMLInputElement;
|
|
117
|
+
this._seekProgress = shadow.querySelector(".avp-seek-progress") as HTMLDivElement;
|
|
118
|
+
this._seekBuffered = shadow.querySelector(".avp-seek-buffered") as HTMLDivElement;
|
|
119
|
+
this._seekThumb = shadow.querySelector(".avp-seek-thumb") as HTMLDivElement;
|
|
120
|
+
this._seekTooltip = shadow.querySelector(".avp-seek-tooltip") as HTMLDivElement;
|
|
121
|
+
this._timeDisplay = shadow.querySelector(".avp-time") as HTMLSpanElement;
|
|
122
|
+
this._volumeBtn = shadow.querySelector(".avp-volume-btn") as HTMLButtonElement;
|
|
123
|
+
this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement;
|
|
124
|
+
this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement;
|
|
125
|
+
this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement;
|
|
126
|
+
this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement;
|
|
127
|
+
// Badge removed from controls bar — strategy visible in Stats for Nerds.
|
|
128
|
+
// Spinner is rendered in shadow DOM, driven by CSS :host([data-state]).
|
|
129
|
+
this._speedIndicator = shadow.querySelector(".avp-speed-indicator") as HTMLDivElement;
|
|
130
|
+
this._statsEl = shadow.querySelector(".avp-stats") as HTMLDivElement;
|
|
131
|
+
this._rippleLeft = shadow.querySelector(".avp-ripple-left") as HTMLDivElement;
|
|
132
|
+
this._rippleRight = shadow.querySelector(".avp-ripple-right") as HTMLDivElement;
|
|
133
|
+
|
|
134
|
+
this._bindEvents();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private _template(): string {
|
|
138
|
+
return `
|
|
139
|
+
<div part="container" class="avp">
|
|
140
|
+
<avbridge-video part="video"></avbridge-video>
|
|
141
|
+
<div part="overlay" class="avp-overlay">
|
|
142
|
+
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
143
|
+
<div class="avp-spinner"></div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="avp-speed-indicator">2x</div>
|
|
146
|
+
<div class="avp-stats" part="stats-panel"></div>
|
|
147
|
+
<div class="avp-ripple avp-ripple-left">${ICON_REPLAY_10}</div>
|
|
148
|
+
<div class="avp-ripple avp-ripple-right">${ICON_FORWARD_10}</div>
|
|
149
|
+
<div part="controls" class="avp-controls">
|
|
150
|
+
<div class="avp-seek" part="seek-bar">
|
|
151
|
+
<div class="avp-seek-track">
|
|
152
|
+
<div class="avp-seek-buffered"></div>
|
|
153
|
+
<div class="avp-seek-progress"></div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="avp-seek-thumb"></div>
|
|
156
|
+
<div class="avp-seek-tooltip">0:00</div>
|
|
157
|
+
<input class="avp-seek-input" type="range" min="0" max="0" step="any" value="0" aria-label="Seek">
|
|
158
|
+
</div>
|
|
159
|
+
<div class="avp-bottom">
|
|
160
|
+
<button class="avp-btn avp-play" part="play-button" aria-label="Play">${ICON_PLAY}</button>
|
|
161
|
+
<div class="avp-volume">
|
|
162
|
+
<button class="avp-btn avp-volume-btn" part="volume-button" aria-label="Mute">${ICON_VOLUME_UP}</button>
|
|
163
|
+
<div class="avp-volume-slider">
|
|
164
|
+
<input class="avp-volume-input" part="volume-slider" type="range" min="0" max="1" step="0.05" value="1" aria-label="Volume">
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<span class="avp-time" part="time-display">0:00 / 0:00</span>
|
|
168
|
+
<span class="avp-spacer"></span>
|
|
169
|
+
<button class="avp-btn avp-settings-btn" part="settings-button" aria-label="Settings">${ICON_SETTINGS}</button>
|
|
170
|
+
<button class="avp-btn avp-fullscreen" part="fullscreen-button" aria-label="Fullscreen">${ICON_FULLSCREEN}</button>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="avp-settings" part="settings-menu"></div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Event wiring ───────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
private _bindEvents(): void {
|
|
180
|
+
const on = <K extends keyof HTMLElementEventMap>(
|
|
181
|
+
el: EventTarget, event: K | string, fn: (e: Event) => void, opts?: AddEventListenerOptions,
|
|
182
|
+
) => {
|
|
183
|
+
el.addEventListener(event, fn, opts);
|
|
184
|
+
this._eventCleanup.push(() => el.removeEventListener(event, fn, opts));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Forward events from inner video
|
|
188
|
+
for (const name of FORWARDED_EVENTS) {
|
|
189
|
+
on(this._video, name, (e) => {
|
|
190
|
+
const detail = (e as CustomEvent).detail;
|
|
191
|
+
this.dispatchEvent(
|
|
192
|
+
detail !== undefined
|
|
193
|
+
? new CustomEvent(name, { detail, bubbles: e.bubbles, composed: true })
|
|
194
|
+
: new Event(name, { bubbles: e.bubbles }),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// State tracking
|
|
200
|
+
on(this._video, "loadstart", () => this._setState("loading"));
|
|
201
|
+
on(this._video, "ready", () => {
|
|
202
|
+
this._setState(this._video.paused ? "paused" : "playing");
|
|
203
|
+
this._seekInput.max = String(this._video.duration || 0);
|
|
204
|
+
this._updateTime();
|
|
205
|
+
this._buildSettingsMenu();
|
|
206
|
+
});
|
|
207
|
+
on(this._video, "play", () => this._setState("playing"));
|
|
208
|
+
on(this._video, "playing", () => this._setState("playing"));
|
|
209
|
+
on(this._video, "pause", () => this._setState("paused"));
|
|
210
|
+
on(this._video, "waiting", () => this._setState("buffering"));
|
|
211
|
+
on(this._video, "ended", () => this._setState("ended"));
|
|
212
|
+
on(this._video, "error", () => this._setState("error"));
|
|
213
|
+
on(this._video, "timeupdate", () => this._updateTime());
|
|
214
|
+
on(this._video, "volumechange", () => this._updateVolume());
|
|
215
|
+
// Strategy changes are visible in Stats for Nerds.
|
|
216
|
+
on(this._video, "trackschange", () => this._buildSettingsMenu());
|
|
217
|
+
on(this._video, "durationchange", () => {
|
|
218
|
+
this._seekInput.max = String(this._video.duration || 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Play / pause
|
|
222
|
+
on(this._playBtn, "click", (e) => { e.stopPropagation(); this._togglePlay(); });
|
|
223
|
+
on(this._overlayBtn, "click", (e) => { e.stopPropagation(); this._togglePlay(); });
|
|
224
|
+
|
|
225
|
+
// Seek bar — manual pointer handling so the click position maps
|
|
226
|
+
// linearly across the FULL track width (native <input type="range">
|
|
227
|
+
// clamps the thumb center inside [thumbWidth/2, trackWidth -
|
|
228
|
+
// thumbWidth/2], which causes a visible click-to-thumb offset at
|
|
229
|
+
// the edges). The input is still used for keyboard accessibility
|
|
230
|
+
// (arrow keys, home/end) via its 'input' event.
|
|
231
|
+
on(this._seekInput, "input", () => {
|
|
232
|
+
// Only accept keyboard-driven input events (not synthesized
|
|
233
|
+
// from pointer, which we handle manually below).
|
|
234
|
+
if (this._userSeeking) return;
|
|
235
|
+
this._onSeekInput();
|
|
236
|
+
});
|
|
237
|
+
on(this._seekInput, "change", () => {
|
|
238
|
+
if (this._userSeeking) return;
|
|
239
|
+
this._onSeekCommit();
|
|
240
|
+
});
|
|
241
|
+
const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
|
|
242
|
+
on(seekBar, "pointerdown", (e) => this._onSeekPointerDown(e as PointerEvent));
|
|
243
|
+
on(seekBar, "pointermove", (e) => this._onSeekHover(e as PointerEvent));
|
|
244
|
+
|
|
245
|
+
// Volume
|
|
246
|
+
on(this._volumeBtn, "click", (e) => { e.stopPropagation(); this._toggleMute(); });
|
|
247
|
+
on(this._volumeInput, "input", () => {
|
|
248
|
+
const vol = Number(this._volumeInput.value);
|
|
249
|
+
this._video.volume = vol;
|
|
250
|
+
this._video.videoElement.volume = vol;
|
|
251
|
+
this._video.muted = false;
|
|
252
|
+
this._video.videoElement.muted = false;
|
|
253
|
+
this._updateVolume();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Settings
|
|
257
|
+
on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); });
|
|
258
|
+
|
|
259
|
+
// Fullscreen
|
|
260
|
+
on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); });
|
|
261
|
+
on(document, "fullscreenchange", () => this._updateFullscreenIcon());
|
|
262
|
+
|
|
263
|
+
// Click / tap on video area — uses a delayed-tap pattern (like YouTube)
|
|
264
|
+
// to distinguish single-tap (play/pause) from double-tap (seek ±10s).
|
|
265
|
+
// On mouse: single click → play/pause, dblclick → fullscreen.
|
|
266
|
+
// On touch: single tap (after 250ms) → play/pause, double tap → seek.
|
|
267
|
+
const container = this.shadowRoot!.querySelector(".avp")!;
|
|
268
|
+
on(container, "click", (e) => this._onContainerClick(e as MouseEvent));
|
|
269
|
+
on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent));
|
|
270
|
+
|
|
271
|
+
// Dismiss settings menu on click outside (inside or outside the player)
|
|
272
|
+
on(container, "click", (e) => {
|
|
273
|
+
if (this._settingsOpen &&
|
|
274
|
+
!(e.target as HTMLElement).closest?.(".avp-settings-btn, .avp-settings")) {
|
|
275
|
+
this._closeSettings();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// Also dismiss if user clicks outside the player element entirely
|
|
279
|
+
on(document, "click", (e) => {
|
|
280
|
+
if (this._settingsOpen && !this.contains(e.target as Node)) {
|
|
281
|
+
this._closeSettings();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Auto-hide controls
|
|
286
|
+
on(container, "pointermove", () => this._showControls());
|
|
287
|
+
on(container, "pointerleave", () => this._scheduleHide());
|
|
288
|
+
|
|
289
|
+
// Touch gestures: hold for 2x speed
|
|
290
|
+
on(container, "pointerdown", (e) => this._onPointerDown(e as PointerEvent));
|
|
291
|
+
on(container, "pointerup", (e) => this._onPointerUp(e as PointerEvent));
|
|
292
|
+
on(container, "pointercancel", () => this._cancelHold());
|
|
293
|
+
|
|
294
|
+
// Keyboard
|
|
295
|
+
on(this, "keydown", (e) => this._onKeydown(e as KeyboardEvent));
|
|
296
|
+
|
|
297
|
+
// Make focusable for keyboard events
|
|
298
|
+
if (!this.hasAttribute("tabindex")) {
|
|
299
|
+
this.setAttribute("tabindex", "0");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
connectedCallback(): void {
|
|
306
|
+
this._setState("idle");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
disconnectedCallback(): void {
|
|
310
|
+
this._clearTimers();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
314
|
+
// Proxy attributes down to inner avbridge-video
|
|
315
|
+
if (!this._video) return;
|
|
316
|
+
if (value == null) this._video.removeAttribute(name);
|
|
317
|
+
else this._video.setAttribute(name, value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── State management ───────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
private _setState(state: PlayerState): void {
|
|
323
|
+
this._state = state;
|
|
324
|
+
this.dataset.state = state;
|
|
325
|
+
|
|
326
|
+
// Update play/pause icons
|
|
327
|
+
const playing = state === "playing" || state === "buffering";
|
|
328
|
+
this._playBtn.innerHTML = playing ? ICON_PAUSE : ICON_PLAY;
|
|
329
|
+
this._playBtn.ariaLabel = playing ? "Pause" : "Play";
|
|
330
|
+
this._overlayBtn.innerHTML = ICON_PLAY;
|
|
331
|
+
|
|
332
|
+
// Auto-hide logic
|
|
333
|
+
if (playing) this._scheduleHide();
|
|
334
|
+
else this._showControls();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Controls: play/pause ───────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
private _togglePlay(): void {
|
|
340
|
+
if (this._state === "idle" || this._state === "error") return;
|
|
341
|
+
if (this._video.paused) void this._video.play();
|
|
342
|
+
else this._video.pause();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Controls: seek ─────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
private _onSeekInput(): void {
|
|
348
|
+
const t = Number(this._seekInput.value);
|
|
349
|
+
this._updateSeekVisuals(t);
|
|
350
|
+
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private _onSeekCommit(): void {
|
|
354
|
+
this._video.currentTime = Number(this._seekInput.value);
|
|
355
|
+
this._userSeeking = false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Linear click-to-time mapping across the full track width (no edge clamping). */
|
|
359
|
+
private _timeFromSeekPointer(clientX: number): number {
|
|
360
|
+
const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
|
|
361
|
+
const rect = seekBar.getBoundingClientRect();
|
|
362
|
+
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
363
|
+
return frac * (this._video.duration || 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private _onSeekPointerDown(e: PointerEvent): void {
|
|
367
|
+
// Ignore synthetic clicks originating from the input's own handling
|
|
368
|
+
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
this._userSeeking = true;
|
|
371
|
+
const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
|
|
372
|
+
seekBar.setPointerCapture(e.pointerId);
|
|
373
|
+
|
|
374
|
+
const initial = this._timeFromSeekPointer(e.clientX);
|
|
375
|
+
this._seekInput.value = String(initial);
|
|
376
|
+
this._onSeekInput();
|
|
377
|
+
|
|
378
|
+
const onMove = (ev: PointerEvent) => {
|
|
379
|
+
const t = this._timeFromSeekPointer(ev.clientX);
|
|
380
|
+
this._seekInput.value = String(t);
|
|
381
|
+
this._onSeekInput();
|
|
382
|
+
};
|
|
383
|
+
const onUp = (ev: PointerEvent) => {
|
|
384
|
+
const t = this._timeFromSeekPointer(ev.clientX);
|
|
385
|
+
this._seekInput.value = String(t);
|
|
386
|
+
this._onSeekCommit();
|
|
387
|
+
this._seekInput.focus(); // keep keyboard nav responsive
|
|
388
|
+
seekBar.removeEventListener("pointermove", onMove);
|
|
389
|
+
seekBar.removeEventListener("pointerup", onUp);
|
|
390
|
+
seekBar.removeEventListener("pointercancel", onUp);
|
|
391
|
+
try { seekBar.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
392
|
+
};
|
|
393
|
+
seekBar.addEventListener("pointermove", onMove);
|
|
394
|
+
seekBar.addEventListener("pointerup", onUp);
|
|
395
|
+
seekBar.addEventListener("pointercancel", onUp);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private _onSeekHover(e: PointerEvent): void {
|
|
399
|
+
const rect = this._seekInput.getBoundingClientRect();
|
|
400
|
+
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
401
|
+
const t = frac * (this._video.duration || 0);
|
|
402
|
+
this._seekTooltip.textContent = formatTime(t);
|
|
403
|
+
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private _updateSeekVisuals(t: number): void {
|
|
407
|
+
const dur = this._video.duration || 0;
|
|
408
|
+
const pct = dur > 0 ? (t / dur) * 100 : 0;
|
|
409
|
+
this._seekProgress.style.width = `${pct}%`;
|
|
410
|
+
// Thumb position uses a CSS calc that matches the native range input's
|
|
411
|
+
// click-to-value math (thumb stays within track bounds). See player-styles.ts.
|
|
412
|
+
this._seekThumb.style.setProperty("--pct", String(pct));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Controls: time ─────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
private _updateTime(): void {
|
|
418
|
+
if (this._userSeeking) return;
|
|
419
|
+
const t = this._video.currentTime;
|
|
420
|
+
const d = this._video.duration;
|
|
421
|
+
this._seekInput.value = String(t);
|
|
422
|
+
this._updateSeekVisuals(t);
|
|
423
|
+
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
|
|
424
|
+
|
|
425
|
+
// Buffered ranges
|
|
426
|
+
try {
|
|
427
|
+
const buf = this._video.buffered;
|
|
428
|
+
if (buf && buf.length > 0 && d > 0) {
|
|
429
|
+
const end = buf.end(buf.length - 1);
|
|
430
|
+
this._seekBuffered.style.width = `${(end / d) * 100}%`;
|
|
431
|
+
}
|
|
432
|
+
} catch { /* ignore */ }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Controls: volume ───────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
private _toggleMute(): void {
|
|
438
|
+
// Set both the element attribute AND the inner <video> property directly,
|
|
439
|
+
// because avbridge-video's attribute-based muted toggling can diverge
|
|
440
|
+
// from the <video> property on a running element.
|
|
441
|
+
const newMuted = !this._video.muted;
|
|
442
|
+
this._video.muted = newMuted;
|
|
443
|
+
this._video.videoElement.muted = newMuted;
|
|
444
|
+
this._updateVolume();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private _updateVolume(): void {
|
|
448
|
+
const muted = this._video.muted || this._video.videoElement.muted || this._video.volume === 0;
|
|
449
|
+
this._volumeBtn.innerHTML = muted ? ICON_VOLUME_OFF : ICON_VOLUME_UP;
|
|
450
|
+
this._volumeInput.value = muted ? "0" : String(this._video.volume);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Controls: settings ─────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
private _toggleSettings(): void {
|
|
456
|
+
this._settingsOpen = !this._settingsOpen;
|
|
457
|
+
this._settingsMenu.classList.toggle("open", this._settingsOpen);
|
|
458
|
+
if (this._settingsOpen) this._showControls();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private _closeSettings(): void {
|
|
462
|
+
this._settingsOpen = false;
|
|
463
|
+
this._settingsMenu.classList.remove("open");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private _buildSettingsMenu(): void {
|
|
467
|
+
const sections: string[] = [];
|
|
468
|
+
|
|
469
|
+
// Playback speed
|
|
470
|
+
const currentRate = this._video.playbackRate ?? 1;
|
|
471
|
+
let speedItems = "";
|
|
472
|
+
for (const spd of PLAYBACK_SPEEDS) {
|
|
473
|
+
const active = Math.abs(spd - currentRate) < 0.01;
|
|
474
|
+
const label = spd === 1 ? "Normal" : `${spd}x`;
|
|
475
|
+
speedItems += `<div class="avp-settings-item${active ? " active" : ""}" data-speed="${spd}">${label}</div>`;
|
|
476
|
+
}
|
|
477
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Speed</div>${speedItems}</div>`);
|
|
478
|
+
|
|
479
|
+
// Subtitle tracks
|
|
480
|
+
const subs = this._video.subtitleTracks ?? [];
|
|
481
|
+
if (subs.length > 0) {
|
|
482
|
+
let subItems = `<div class="avp-settings-item" data-subtitle="-1">Off</div>`;
|
|
483
|
+
for (const t of subs) {
|
|
484
|
+
subItems += `<div class="avp-settings-item" data-subtitle="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
|
|
485
|
+
}
|
|
486
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Subtitles</div>${subItems}</div>`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Audio tracks
|
|
490
|
+
const audios = this._video.audioTracks ?? [];
|
|
491
|
+
if (audios.length > 1) {
|
|
492
|
+
let audioItems = "";
|
|
493
|
+
for (const t of audios) {
|
|
494
|
+
audioItems += `<div class="avp-settings-item" data-audio="${t.id}">${t.language ?? `Track ${t.id}`}</div>`;
|
|
495
|
+
}
|
|
496
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Audio</div>${audioItems}</div>`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Stats for nerds
|
|
500
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
|
|
501
|
+
|
|
502
|
+
this._settingsMenu.innerHTML = sections.join("");
|
|
503
|
+
|
|
504
|
+
// Bind click handlers
|
|
505
|
+
for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
|
|
506
|
+
item.addEventListener("click", (e) => {
|
|
507
|
+
e.stopPropagation();
|
|
508
|
+
this._video.playbackRate = Number((item as HTMLElement).dataset.speed);
|
|
509
|
+
this._buildSettingsMenu();
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
for (const item of this._settingsMenu.querySelectorAll("[data-subtitle]")) {
|
|
513
|
+
item.addEventListener("click", (e) => {
|
|
514
|
+
e.stopPropagation();
|
|
515
|
+
const id = Number((item as HTMLElement).dataset.subtitle);
|
|
516
|
+
void this._video.setSubtitleTrack(id >= 0 ? id : null);
|
|
517
|
+
this._closeSettings();
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
for (const item of this._settingsMenu.querySelectorAll("[data-audio]")) {
|
|
521
|
+
item.addEventListener("click", (e) => {
|
|
522
|
+
e.stopPropagation();
|
|
523
|
+
void this._video.setAudioTrack(Number((item as HTMLElement).dataset.audio));
|
|
524
|
+
this._closeSettings();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const statsItem = this._settingsMenu.querySelector("[data-stats]");
|
|
528
|
+
if (statsItem) {
|
|
529
|
+
statsItem.addEventListener("click", (e) => {
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
this._toggleStats();
|
|
532
|
+
this._closeSettings();
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Stats for nerds ────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
private _toggleStats(): void {
|
|
540
|
+
this._statsOpen = !this._statsOpen;
|
|
541
|
+
this._statsEl.classList.toggle("open", this._statsOpen);
|
|
542
|
+
if (this._statsOpen) {
|
|
543
|
+
this._updateStats();
|
|
544
|
+
this._statsInterval = setInterval(() => this._updateStats(), 1000);
|
|
545
|
+
} else {
|
|
546
|
+
if (this._statsInterval) { clearInterval(this._statsInterval); this._statsInterval = null; }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private _updateStats(): void {
|
|
551
|
+
const d = this._video.getDiagnostics() as Record<string, unknown> | null;
|
|
552
|
+
if (!d) { this._statsEl.textContent = "No diagnostics"; return; }
|
|
553
|
+
const rt = (d.runtime ?? {}) as Record<string, unknown>;
|
|
554
|
+
const lines: string[] = [
|
|
555
|
+
`Container: ${d.container ?? "?"}`,
|
|
556
|
+
`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}`,
|
|
557
|
+
`Audio: ${d.audioCodec ?? "none"}`,
|
|
558
|
+
`Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
|
|
559
|
+
`Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
|
|
560
|
+
`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`,
|
|
561
|
+
];
|
|
562
|
+
if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
|
|
563
|
+
if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
|
|
564
|
+
if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
|
|
565
|
+
if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
|
|
566
|
+
if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
|
|
567
|
+
if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF: ${(rt.bsfApplied as string[]).join(", ")}`);
|
|
568
|
+
if (rt.audioState != null) lines.push(`Audio state: ${rt.audioState} Clock: ${rt.clockMode ?? "?"}`);
|
|
569
|
+
if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
|
|
570
|
+
this._statsEl.textContent = lines.join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Controls: fullscreen ───────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
private _toggleFullscreen(): void {
|
|
576
|
+
if (document.fullscreenElement === this) {
|
|
577
|
+
void document.exitFullscreen();
|
|
578
|
+
} else {
|
|
579
|
+
void this.requestFullscreen();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private _updateFullscreenIcon(): void {
|
|
584
|
+
const fs = document.fullscreenElement === this;
|
|
585
|
+
this._fullscreenBtn.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── Controls: auto-hide ────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
private _showControls(): void {
|
|
591
|
+
this.removeAttribute("data-controls-hidden");
|
|
592
|
+
this._scheduleHide();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private _scheduleHide(): void {
|
|
596
|
+
if (this._controlsTimer) clearTimeout(this._controlsTimer);
|
|
597
|
+
if (this._state !== "playing" && this._state !== "buffering") return;
|
|
598
|
+
if (this._settingsOpen) return;
|
|
599
|
+
this._controlsTimer = setTimeout(() => {
|
|
600
|
+
if (this._state === "playing") {
|
|
601
|
+
this.setAttribute("data-controls-hidden", "");
|
|
602
|
+
}
|
|
603
|
+
}, CONTROLS_HIDE_MS);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Strategy is visible in Stats for Nerds, no badge in controls bar.
|
|
607
|
+
|
|
608
|
+
// ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
|
|
609
|
+
//
|
|
610
|
+
// Problem: single click toggles play, double click toggles fullscreen (or
|
|
611
|
+
// seek on touch). Firing play on the first click causes a play→pause
|
|
612
|
+
// glitch on every double-click. YouTube solves this by delaying the
|
|
613
|
+
// single-click action by ~250ms; if a second click arrives in that window
|
|
614
|
+
// it's treated as a double-click and the single-click action is cancelled.
|
|
615
|
+
|
|
616
|
+
/** Track whether the last interaction was touch so click handler can skip. */
|
|
617
|
+
private _lastPointerTypeWasTouch = false;
|
|
618
|
+
|
|
619
|
+
private _onContainerClick(e: MouseEvent): void {
|
|
620
|
+
// Ignore clicks on controls
|
|
621
|
+
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
622
|
+
|
|
623
|
+
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
624
|
+
// The browser fires a synthetic click after touchend — skip it.
|
|
625
|
+
if (this._lastPointerTypeWasTouch) {
|
|
626
|
+
this._lastPointerTypeWasTouch = false;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Mouse: delay single-click to let dblclick cancel it
|
|
631
|
+
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
632
|
+
this._tapTimer = setTimeout(() => {
|
|
633
|
+
this._tapTimer = null;
|
|
634
|
+
this._togglePlay();
|
|
635
|
+
}, 250);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private _onContainerDblClick(e: MouseEvent): void {
|
|
639
|
+
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
|
|
640
|
+
// Cancel the pending single-click play/pause
|
|
641
|
+
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
642
|
+
this._toggleFullscreen();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Touch gestures ─────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
private _onPointerDown(e: PointerEvent): void {
|
|
648
|
+
if (e.pointerType !== "touch") return;
|
|
649
|
+
// Tap-and-hold for 2x speed
|
|
650
|
+
this._holdTimer = setTimeout(() => {
|
|
651
|
+
this._holdSpeedActive = true;
|
|
652
|
+
this._savedPlaybackRate = this._video.playbackRate;
|
|
653
|
+
this._video.playbackRate = 2;
|
|
654
|
+
this._speedIndicator.classList.add("active");
|
|
655
|
+
}, 500);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private _onPointerUp(e: PointerEvent): void {
|
|
659
|
+
this._cancelHold();
|
|
660
|
+
if (e.pointerType !== "touch") return;
|
|
661
|
+
this._lastPointerTypeWasTouch = true;
|
|
662
|
+
|
|
663
|
+
// Ignore touches on controls — buttons have their own handlers
|
|
664
|
+
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
665
|
+
|
|
666
|
+
// Double-tap detection
|
|
667
|
+
const now = Date.now();
|
|
668
|
+
if (now - this._lastTapTime < 300) {
|
|
669
|
+
// Double tap — cancel pending single tap and seek
|
|
670
|
+
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
671
|
+
const rect = this.getBoundingClientRect();
|
|
672
|
+
const x = e.clientX - rect.left;
|
|
673
|
+
if (x < rect.width / 3) {
|
|
674
|
+
this._doDoubleTap("left");
|
|
675
|
+
} else if (x > (rect.width * 2) / 3) {
|
|
676
|
+
this._doDoubleTap("right");
|
|
677
|
+
} else {
|
|
678
|
+
this._toggleFullscreen();
|
|
679
|
+
}
|
|
680
|
+
this._lastTapTime = 0;
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// Single tap on touch — toggle controls visibility (NOT play/pause).
|
|
684
|
+
// YouTube mobile: tap shows/hides controls. Play button toggles playback.
|
|
685
|
+
this._lastTapTime = now;
|
|
686
|
+
this._tapTimer = setTimeout(() => {
|
|
687
|
+
this._tapTimer = null;
|
|
688
|
+
if (this.hasAttribute("data-controls-hidden")) {
|
|
689
|
+
this._showControls();
|
|
690
|
+
} else {
|
|
691
|
+
this.setAttribute("data-controls-hidden", "");
|
|
692
|
+
}
|
|
693
|
+
}, 250);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private _cancelHold(): void {
|
|
697
|
+
if (this._holdTimer) {
|
|
698
|
+
clearTimeout(this._holdTimer);
|
|
699
|
+
this._holdTimer = null;
|
|
700
|
+
}
|
|
701
|
+
if (this._holdSpeedActive) {
|
|
702
|
+
this._holdSpeedActive = false;
|
|
703
|
+
this._video.playbackRate = this._savedPlaybackRate;
|
|
704
|
+
this._speedIndicator.classList.remove("active");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private _doDoubleTap(side: "left" | "right"): void {
|
|
709
|
+
const ripple = side === "left" ? this._rippleLeft : this._rippleRight;
|
|
710
|
+
ripple.classList.remove("active");
|
|
711
|
+
// Force reflow to restart animation
|
|
712
|
+
void ripple.offsetWidth;
|
|
713
|
+
ripple.classList.add("active");
|
|
714
|
+
|
|
715
|
+
const delta = side === "left" ? -10 : 10;
|
|
716
|
+
this._video.currentTime = Math.max(0, this._video.currentTime + delta);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── Keyboard shortcuts ─────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
private _onKeydown(e: KeyboardEvent): void {
|
|
722
|
+
// Don't intercept if the user is typing in an input
|
|
723
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
724
|
+
|
|
725
|
+
switch (e.key) {
|
|
726
|
+
case " ":
|
|
727
|
+
case "k":
|
|
728
|
+
e.preventDefault();
|
|
729
|
+
this._togglePlay();
|
|
730
|
+
break;
|
|
731
|
+
case "f":
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
this._toggleFullscreen();
|
|
734
|
+
break;
|
|
735
|
+
case "m":
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
this._toggleMute();
|
|
738
|
+
break;
|
|
739
|
+
case "ArrowLeft":
|
|
740
|
+
case "j":
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
this._video.currentTime = Math.max(0, this._video.currentTime - 5);
|
|
743
|
+
break;
|
|
744
|
+
case "ArrowRight":
|
|
745
|
+
case "l":
|
|
746
|
+
e.preventDefault();
|
|
747
|
+
this._video.currentTime = Math.min(this._video.duration || 0, this._video.currentTime + 5);
|
|
748
|
+
break;
|
|
749
|
+
case "ArrowUp":
|
|
750
|
+
e.preventDefault();
|
|
751
|
+
this._video.volume = Math.min(1, this._video.volume + 0.1);
|
|
752
|
+
break;
|
|
753
|
+
case "ArrowDown":
|
|
754
|
+
e.preventDefault();
|
|
755
|
+
this._video.volume = Math.max(0, this._video.volume - 0.1);
|
|
756
|
+
break;
|
|
757
|
+
case ">":
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
this._video.playbackRate = Math.min(2, this._video.playbackRate + 0.25);
|
|
760
|
+
this._buildSettingsMenu();
|
|
761
|
+
break;
|
|
762
|
+
case "<":
|
|
763
|
+
e.preventDefault();
|
|
764
|
+
this._video.playbackRate = Math.max(0.25, this._video.playbackRate - 0.25);
|
|
765
|
+
this._buildSettingsMenu();
|
|
766
|
+
break;
|
|
767
|
+
case "Escape":
|
|
768
|
+
if (this._settingsOpen) {
|
|
769
|
+
e.preventDefault();
|
|
770
|
+
this._closeSettings();
|
|
771
|
+
}
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
this._showControls();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── Cleanup ────────────────────────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
private _clearTimers(): void {
|
|
780
|
+
if (this._controlsTimer) { clearTimeout(this._controlsTimer); this._controlsTimer = null; }
|
|
781
|
+
if (this._holdTimer) { clearTimeout(this._holdTimer); this._holdTimer = null; }
|
|
782
|
+
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
783
|
+
if (this._statsInterval) { clearInterval(this._statsInterval); this._statsInterval = null; }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── Property proxies ───────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
get src(): string { return this._video.src ?? ""; }
|
|
789
|
+
set src(v: string) { this._video.src = v; }
|
|
790
|
+
|
|
791
|
+
get source(): unknown { return this._video.source; }
|
|
792
|
+
set source(v: unknown) { (this._video as unknown as { source: unknown }).source = v; }
|
|
793
|
+
|
|
794
|
+
get currentTime(): number { return this._video.currentTime; }
|
|
795
|
+
set currentTime(v: number) { this._video.currentTime = v; }
|
|
796
|
+
|
|
797
|
+
get duration(): number { return this._video.duration; }
|
|
798
|
+
get paused(): boolean { return this._video.paused; }
|
|
799
|
+
get ended(): boolean { return this._video.ended; }
|
|
800
|
+
get readyState(): number { return this._video.readyState; }
|
|
801
|
+
|
|
802
|
+
get volume(): number { return this._video.volume; }
|
|
803
|
+
set volume(v: number) { this._video.volume = v; this._updateVolume(); }
|
|
804
|
+
|
|
805
|
+
get muted(): boolean { return this._video.muted; }
|
|
806
|
+
set muted(v: boolean) { this._video.muted = v; this._updateVolume(); }
|
|
807
|
+
|
|
808
|
+
get playbackRate(): number { return this._video.playbackRate; }
|
|
809
|
+
set playbackRate(v: number) { this._video.playbackRate = v; }
|
|
810
|
+
|
|
811
|
+
get autoplay(): boolean { return this._video.autoplay; }
|
|
812
|
+
set autoplay(v: boolean) { this._video.autoplay = v; }
|
|
813
|
+
|
|
814
|
+
get loop(): boolean { return this._video.loop; }
|
|
815
|
+
set loop(v: boolean) { this._video.loop = v; }
|
|
816
|
+
|
|
817
|
+
get videoWidth(): number { return this._video.videoWidth; }
|
|
818
|
+
get videoHeight(): number { return this._video.videoHeight; }
|
|
819
|
+
get buffered(): TimeRanges { return this._video.buffered; }
|
|
820
|
+
get played(): TimeRanges { return this._video.played; }
|
|
821
|
+
get seekable(): TimeRanges { return this._video.seekable; }
|
|
822
|
+
|
|
823
|
+
get strategy(): string | undefined { return this._video.strategy ?? undefined; }
|
|
824
|
+
get strategyClass(): string | undefined { return this._video.strategyClass ?? undefined; }
|
|
825
|
+
get audioTracks(): unknown[] { return this._video.audioTracks ?? []; }
|
|
826
|
+
get subtitleTracks(): unknown[] { return this._video.subtitleTracks ?? []; }
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* External subtitle files to attach when the source loads. Forwarded
|
|
830
|
+
* to the inner <avbridge-video>. Takes effect on next bootstrap.
|
|
831
|
+
*/
|
|
832
|
+
get subtitles(): unknown {
|
|
833
|
+
return (this._video as unknown as { subtitles: unknown }).subtitles;
|
|
834
|
+
}
|
|
835
|
+
set subtitles(value: unknown) {
|
|
836
|
+
(this._video as unknown as { subtitles: unknown }).subtitles = value;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Attach a subtitle track to the current playback without a reload. */
|
|
840
|
+
async addSubtitle(subtitle: { url: string; language?: string; format?: "vtt" | "srt" }): Promise<void> {
|
|
841
|
+
return (this._video as unknown as { addSubtitle: (s: unknown) => Promise<void> }).addSubtitle(subtitle);
|
|
842
|
+
}
|
|
843
|
+
get player(): unknown { return this._video.player; }
|
|
844
|
+
get videoElement(): HTMLVideoElement { return this._video.videoElement; }
|
|
845
|
+
|
|
846
|
+
// ── Method proxies ─────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
async play(): Promise<void> { return this._video.play(); }
|
|
849
|
+
pause(): void { this._video.pause(); }
|
|
850
|
+
async load(): Promise<void> { return this._video.load(); }
|
|
851
|
+
async destroy(): Promise<void> {
|
|
852
|
+
this._clearTimers();
|
|
853
|
+
for (const fn of this._eventCleanup) fn();
|
|
854
|
+
this._eventCleanup = [];
|
|
855
|
+
return this._video.destroy();
|
|
856
|
+
}
|
|
857
|
+
async setAudioTrack(id: number): Promise<void> { return this._video.setAudioTrack(id); }
|
|
858
|
+
async setSubtitleTrack(id: number | null): Promise<void> { return this._video.setSubtitleTrack(id); }
|
|
859
|
+
getDiagnostics(): unknown { return this._video.getDiagnostics(); }
|
|
860
|
+
canPlayType(mime: string): string { return this._video.canPlayType(mime); }
|
|
861
|
+
}
|