playsvideo 0.4.4 → 0.4.6
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/dist/custom-controls.d.ts +18 -0
- package/dist/custom-controls.d.ts.map +1 -0
- package/dist/custom-controls.js +688 -0
- package/dist/custom-controls.js.map +1 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -3
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/demux.d.ts +2 -1
- package/dist/pipeline/demux.d.ts.map +1 -1
- package/dist/pipeline/demux.js +18 -2
- package/dist/pipeline/demux.js.map +1 -1
- package/dist/pipeline/mkv-keyframe-index.d.ts +1 -1
- package/dist/pipeline/mkv-keyframe-index.d.ts.map +1 -1
- package/dist/playback-selection.js +1 -1
- package/dist/playback-selection.js.map +1 -1
- package/dist/source.d.ts +19 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +21 -0
- package/dist/source.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
2
|
+
// --- SVG Icons (24x24 viewBox, white fill) ---
|
|
3
|
+
const svg = (d, vb = '0 0 24 24') => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}" fill="currentColor">${d}</svg>`;
|
|
4
|
+
const ICON = {
|
|
5
|
+
play: svg('<path d="M8 5v14l11-7z"/>'),
|
|
6
|
+
pause: svg('<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>'),
|
|
7
|
+
skipBack: svg('<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="12" y="16.5" text-anchor="middle" font-size="7.5" font-weight="700" font-family="sans-serif" fill="currentColor">10</text>'),
|
|
8
|
+
skipFwd: svg('<path d="M12.01 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/><text x="12" y="16.5" text-anchor="middle" font-size="7.5" font-weight="700" font-family="sans-serif" fill="currentColor">10</text>'),
|
|
9
|
+
volumeHigh: svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 8.14v7.72c1.48-.73 2.5-2.25 2.5-3.86zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
|
|
10
|
+
volumeMuted: svg('<path d="M16.5 12A4.5 4.5 0 0014 8.14v2.72l2.44 2.44c.03-.1.06-.2.06-.3zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.8 8.8 0 0021 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 003.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
|
|
11
|
+
fsEnter: svg('<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>'),
|
|
12
|
+
fsExit: svg('<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>'),
|
|
13
|
+
overflow: svg('<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>'),
|
|
14
|
+
cc: svg('<path d="M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z"/>'),
|
|
15
|
+
speed: svg('<path d="M20.38 8.57l-1.23 1.85a8 8 0 01-.22 7.58H5.07A8 8 0 0115.58 6.85l1.85-1.23A10 10 0 003.35 19a2 2 0 001.72 1h13.85a2 2 0 001.74-1 10 10 0 00-.27-11.44zM10.59 15.41a2 2 0 002.83 0l5.66-8.49-8.49 5.66a2 2 0 000 2.83z"/>'),
|
|
16
|
+
pip: svg('<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/>'),
|
|
17
|
+
};
|
|
18
|
+
const isTouch = matchMedia('(pointer: coarse)').matches;
|
|
19
|
+
const CONTROLS_CSS = `
|
|
20
|
+
.pv-video-container { position: relative; }
|
|
21
|
+
.pv-video-container:fullscreen, .pv-video-container.pv-pip { background: #000; }
|
|
22
|
+
.pv-video-container:fullscreen video, .pv-video-container.pv-pip video { width: 100%; height: 100%; object-fit: contain; }
|
|
23
|
+
.pv-video-container.pv-pip { width: 100vw; height: 100vh; }
|
|
24
|
+
|
|
25
|
+
/* Overlay wrapper */
|
|
26
|
+
.pv-overlay {
|
|
27
|
+
position: absolute;
|
|
28
|
+
inset: 0;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
justify-content: flex-end;
|
|
32
|
+
opacity: 1;
|
|
33
|
+
transition: opacity 0.3s;
|
|
34
|
+
z-index: 10;
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
}
|
|
37
|
+
.pv-overlay.pv-hidden {
|
|
38
|
+
opacity: 0;
|
|
39
|
+
}
|
|
40
|
+
.pv-overlay > * { pointer-events: auto; }
|
|
41
|
+
.pv-overlay.pv-hidden > *:not(.pv-tap-target) { pointer-events: none; }
|
|
42
|
+
|
|
43
|
+
/* Tap target covers entire video for touch show/hide */
|
|
44
|
+
.pv-tap-target {
|
|
45
|
+
position: absolute;
|
|
46
|
+
inset: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Center play button */
|
|
50
|
+
.pv-center {
|
|
51
|
+
position: absolute;
|
|
52
|
+
top: 50%;
|
|
53
|
+
left: 50%;
|
|
54
|
+
transform: translate(-50%, -50%);
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 2rem;
|
|
58
|
+
}
|
|
59
|
+
.pv-center-btn {
|
|
60
|
+
width: 68px;
|
|
61
|
+
height: 68px;
|
|
62
|
+
border-radius: 50%;
|
|
63
|
+
background: rgba(0,0,0,0.5);
|
|
64
|
+
border: none;
|
|
65
|
+
color: #fff;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
padding: 0;
|
|
71
|
+
}
|
|
72
|
+
.pv-center-btn svg { width: 40px; height: 40px; }
|
|
73
|
+
.pv-center-skip {
|
|
74
|
+
width: 44px;
|
|
75
|
+
height: 44px;
|
|
76
|
+
border-radius: 50%;
|
|
77
|
+
background: rgba(0,0,0,0.35);
|
|
78
|
+
border: none;
|
|
79
|
+
color: #fff;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
padding: 0;
|
|
85
|
+
}
|
|
86
|
+
.pv-center-skip svg { width: 26px; height: 26px; }
|
|
87
|
+
|
|
88
|
+
/* Bottom controls */
|
|
89
|
+
.pv-bottom {
|
|
90
|
+
position: relative;
|
|
91
|
+
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
|
92
|
+
padding: 0 0.5rem 0.35rem;
|
|
93
|
+
}
|
|
94
|
+
.pv-seek-row {
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
padding: 0 0.25rem;
|
|
98
|
+
}
|
|
99
|
+
.pv-seek-wrap {
|
|
100
|
+
position: relative;
|
|
101
|
+
flex: 1;
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
}
|
|
105
|
+
.pv-buffered {
|
|
106
|
+
position: absolute;
|
|
107
|
+
left: 0;
|
|
108
|
+
height: 3px;
|
|
109
|
+
width: 0;
|
|
110
|
+
background: rgba(255,255,255,0.5);
|
|
111
|
+
border-radius: 2px;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
}
|
|
114
|
+
.pv-seek {
|
|
115
|
+
width: 100%;
|
|
116
|
+
position: relative;
|
|
117
|
+
z-index: 1;
|
|
118
|
+
-webkit-appearance: none;
|
|
119
|
+
appearance: none;
|
|
120
|
+
height: 3px;
|
|
121
|
+
background: rgba(255,255,255,0.3);
|
|
122
|
+
border-radius: 2px;
|
|
123
|
+
outline: none;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
margin: 0;
|
|
126
|
+
}
|
|
127
|
+
.pv-seek::-webkit-slider-thumb {
|
|
128
|
+
-webkit-appearance: none;
|
|
129
|
+
width: 14px;
|
|
130
|
+
height: 14px;
|
|
131
|
+
background: #fff;
|
|
132
|
+
border-radius: 50%;
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
}
|
|
135
|
+
.pv-btn-row {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 0.15rem;
|
|
139
|
+
padding: 0 0.25rem;
|
|
140
|
+
}
|
|
141
|
+
.pv-btn-row .pv-spacer { flex: 1; }
|
|
142
|
+
|
|
143
|
+
/* Icon buttons */
|
|
144
|
+
.pv-btn {
|
|
145
|
+
background: none;
|
|
146
|
+
border: none;
|
|
147
|
+
color: #fff;
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
padding: 6px;
|
|
150
|
+
line-height: 0;
|
|
151
|
+
border-radius: 4px;
|
|
152
|
+
flex-shrink: 0;
|
|
153
|
+
}
|
|
154
|
+
.pv-btn:hover { background: rgba(255,255,255,0.1); }
|
|
155
|
+
.pv-btn svg { width: 22px; height: 22px; }
|
|
156
|
+
.pv-btn-active { color: var(--accent, #3b82f6); }
|
|
157
|
+
|
|
158
|
+
/* Time display */
|
|
159
|
+
.pv-time {
|
|
160
|
+
font-size: 0.8rem;
|
|
161
|
+
font-variant-numeric: tabular-nums;
|
|
162
|
+
white-space: nowrap;
|
|
163
|
+
color: #fff;
|
|
164
|
+
padding: 0 0.25rem;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Volume slider — hidden on touch devices */
|
|
168
|
+
.pv-vol {
|
|
169
|
+
width: 52px;
|
|
170
|
+
-webkit-appearance: none;
|
|
171
|
+
appearance: none;
|
|
172
|
+
height: 3px;
|
|
173
|
+
background: rgba(255,255,255,0.3);
|
|
174
|
+
border-radius: 2px;
|
|
175
|
+
outline: none;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
margin: 0 2px;
|
|
178
|
+
}
|
|
179
|
+
.pv-vol::-webkit-slider-thumb {
|
|
180
|
+
-webkit-appearance: none;
|
|
181
|
+
width: 10px;
|
|
182
|
+
height: 10px;
|
|
183
|
+
background: #fff;
|
|
184
|
+
border-radius: 50%;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
@media (pointer: coarse) {
|
|
188
|
+
.pv-vol { display: none; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* Popup menu */
|
|
192
|
+
.pv-popup-anchor { position: relative; }
|
|
193
|
+
.pv-popup {
|
|
194
|
+
position: absolute;
|
|
195
|
+
bottom: 100%;
|
|
196
|
+
right: 0;
|
|
197
|
+
background: rgba(20,20,20,0.95);
|
|
198
|
+
border-radius: 8px;
|
|
199
|
+
padding: 0.35rem 0;
|
|
200
|
+
min-width: 160px;
|
|
201
|
+
margin-bottom: 0.5rem;
|
|
202
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
|
203
|
+
z-index: 20;
|
|
204
|
+
}
|
|
205
|
+
.pv-popup-item {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
gap: 0.75rem;
|
|
209
|
+
padding: 0.6rem 1rem;
|
|
210
|
+
color: #fff;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
font-size: 0.85rem;
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
border: none;
|
|
215
|
+
background: none;
|
|
216
|
+
width: 100%;
|
|
217
|
+
text-align: left;
|
|
218
|
+
}
|
|
219
|
+
.pv-popup-item:hover { background: rgba(255,255,255,0.1); }
|
|
220
|
+
.pv-popup-item.pv-active { color: var(--accent, #3b82f6); }
|
|
221
|
+
.pv-popup-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
|
222
|
+
.pv-popup-label { flex: 1; }
|
|
223
|
+
.pv-popup-value { color: rgba(255,255,255,0.5); font-size: 0.8rem; }
|
|
224
|
+
`;
|
|
225
|
+
let styleInjected = false;
|
|
226
|
+
function injectStyles() {
|
|
227
|
+
if (styleInjected)
|
|
228
|
+
return;
|
|
229
|
+
const style = document.createElement('style');
|
|
230
|
+
style.textContent = CONTROLS_CSS;
|
|
231
|
+
document.head.appendChild(style);
|
|
232
|
+
styleInjected = true;
|
|
233
|
+
}
|
|
234
|
+
function formatTime(sec) {
|
|
235
|
+
const h = Math.floor(sec / 3600);
|
|
236
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
237
|
+
const s = Math.floor(sec % 60);
|
|
238
|
+
if (h > 0)
|
|
239
|
+
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
240
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
241
|
+
}
|
|
242
|
+
function iconBtn(label, iconHtml, className = 'pv-btn') {
|
|
243
|
+
const btn = document.createElement('button');
|
|
244
|
+
btn.className = className;
|
|
245
|
+
btn.innerHTML = iconHtml;
|
|
246
|
+
btn.setAttribute('aria-label', label);
|
|
247
|
+
return btn;
|
|
248
|
+
}
|
|
249
|
+
export function createCustomControls(options) {
|
|
250
|
+
const { video, container } = options;
|
|
251
|
+
injectStyles();
|
|
252
|
+
// --- Build DOM ---
|
|
253
|
+
const overlay = document.createElement('div');
|
|
254
|
+
overlay.className = 'pv-overlay';
|
|
255
|
+
// Tap target — covers the video area for touch show/hide
|
|
256
|
+
const tapTarget = document.createElement('div');
|
|
257
|
+
tapTarget.className = 'pv-tap-target';
|
|
258
|
+
// Center play/skip buttons
|
|
259
|
+
const center = document.createElement('div');
|
|
260
|
+
center.className = 'pv-center';
|
|
261
|
+
const skipBackBtn = iconBtn('Skip back 10s', ICON.skipBack, 'pv-center-skip');
|
|
262
|
+
const playBtn = iconBtn('Play/Pause', ICON.play, 'pv-center-btn');
|
|
263
|
+
const skipFwdBtn = iconBtn('Skip forward 10s', ICON.skipFwd, 'pv-center-skip');
|
|
264
|
+
center.append(skipBackBtn, playBtn, skipFwdBtn);
|
|
265
|
+
// Bottom bar
|
|
266
|
+
const bottom = document.createElement('div');
|
|
267
|
+
bottom.className = 'pv-bottom';
|
|
268
|
+
// Seek row
|
|
269
|
+
const seekRow = document.createElement('div');
|
|
270
|
+
seekRow.className = 'pv-seek-row';
|
|
271
|
+
const seekWrap = document.createElement('div');
|
|
272
|
+
seekWrap.className = 'pv-seek-wrap';
|
|
273
|
+
const bufferedBar = document.createElement('div');
|
|
274
|
+
bufferedBar.className = 'pv-buffered';
|
|
275
|
+
const seekBar = document.createElement('input');
|
|
276
|
+
seekBar.type = 'range';
|
|
277
|
+
seekBar.className = 'pv-seek';
|
|
278
|
+
seekBar.min = '0';
|
|
279
|
+
seekBar.max = '0';
|
|
280
|
+
seekBar.step = '0.1';
|
|
281
|
+
seekBar.value = '0';
|
|
282
|
+
seekWrap.append(bufferedBar, seekBar);
|
|
283
|
+
seekRow.appendChild(seekWrap);
|
|
284
|
+
// Button row
|
|
285
|
+
const btnRow = document.createElement('div');
|
|
286
|
+
btnRow.className = 'pv-btn-row';
|
|
287
|
+
const timeDisplay = document.createElement('span');
|
|
288
|
+
timeDisplay.className = 'pv-time';
|
|
289
|
+
timeDisplay.textContent = '0:00 / 0:00';
|
|
290
|
+
const spacer = document.createElement('span');
|
|
291
|
+
spacer.className = 'pv-spacer';
|
|
292
|
+
const volumeBtn = iconBtn('Mute/Unmute', ICON.volumeHigh);
|
|
293
|
+
const volumeBar = document.createElement('input');
|
|
294
|
+
volumeBar.type = 'range';
|
|
295
|
+
volumeBar.className = 'pv-vol';
|
|
296
|
+
volumeBar.min = '0';
|
|
297
|
+
volumeBar.max = '1';
|
|
298
|
+
volumeBar.step = '0.01';
|
|
299
|
+
volumeBar.value = String(video.volume);
|
|
300
|
+
const fsBtn = iconBtn('Fullscreen', ICON.fsEnter);
|
|
301
|
+
// Overflow menu anchor + button
|
|
302
|
+
const overflowAnchor = document.createElement('span');
|
|
303
|
+
overflowAnchor.className = 'pv-popup-anchor';
|
|
304
|
+
const overflowBtn = iconBtn('More options', ICON.overflow);
|
|
305
|
+
overflowAnchor.appendChild(overflowBtn);
|
|
306
|
+
btnRow.append(timeDisplay, spacer, volumeBtn, volumeBar, fsBtn, overflowAnchor);
|
|
307
|
+
bottom.append(seekRow, btnRow);
|
|
308
|
+
overlay.append(tapTarget, center, bottom);
|
|
309
|
+
container.appendChild(overlay);
|
|
310
|
+
// --- State ---
|
|
311
|
+
let seeking = false;
|
|
312
|
+
let hideTimer;
|
|
313
|
+
let activePopup = null;
|
|
314
|
+
const docPipSupported = typeof documentPictureInPicture !== 'undefined';
|
|
315
|
+
const pipSupported = docPipSupported || document.pictureInPictureEnabled;
|
|
316
|
+
// Document PiP state
|
|
317
|
+
let pipWindow = null;
|
|
318
|
+
let originalParent = null;
|
|
319
|
+
let originalNextSibling = null;
|
|
320
|
+
function exitDocumentPip() {
|
|
321
|
+
if (!pipWindow)
|
|
322
|
+
return;
|
|
323
|
+
container.classList.remove('pv-pip');
|
|
324
|
+
if (originalParent) {
|
|
325
|
+
if (originalNextSibling) {
|
|
326
|
+
originalParent.insertBefore(container, originalNextSibling);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
originalParent.appendChild(container);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
pipWindow.close();
|
|
333
|
+
pipWindow = null;
|
|
334
|
+
originalParent = null;
|
|
335
|
+
originalNextSibling = null;
|
|
336
|
+
}
|
|
337
|
+
async function enterDocumentPip() {
|
|
338
|
+
originalParent = container.parentNode;
|
|
339
|
+
originalNextSibling = container.nextSibling;
|
|
340
|
+
const w = video.videoWidth || 640;
|
|
341
|
+
const h = video.videoHeight || 360;
|
|
342
|
+
const scale = Math.min(640 / w, 360 / h, 1);
|
|
343
|
+
pipWindow = await documentPictureInPicture.requestWindow({
|
|
344
|
+
width: Math.round(w * scale),
|
|
345
|
+
height: Math.round(h * scale),
|
|
346
|
+
});
|
|
347
|
+
// Inject styles into the PiP window
|
|
348
|
+
const style = pipWindow.document.createElement('style');
|
|
349
|
+
style.textContent = CONTROLS_CSS;
|
|
350
|
+
pipWindow.document.head.appendChild(style);
|
|
351
|
+
// Move the entire container into the PiP window
|
|
352
|
+
container.classList.add('pv-pip');
|
|
353
|
+
pipWindow.document.body.appendChild(container);
|
|
354
|
+
// When PiP window is closed (user clicks X or programmatic), restore
|
|
355
|
+
pipWindow.addEventListener('pagehide', () => exitDocumentPip());
|
|
356
|
+
}
|
|
357
|
+
// --- Popup helpers ---
|
|
358
|
+
function closePopup() {
|
|
359
|
+
if (activePopup) {
|
|
360
|
+
activePopup.remove();
|
|
361
|
+
activePopup = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function openPopup(anchor, buildItems) {
|
|
365
|
+
closePopup();
|
|
366
|
+
const popup = document.createElement('div');
|
|
367
|
+
popup.className = 'pv-popup';
|
|
368
|
+
for (const item of buildItems())
|
|
369
|
+
popup.appendChild(item);
|
|
370
|
+
anchor.appendChild(popup);
|
|
371
|
+
activePopup = popup;
|
|
372
|
+
}
|
|
373
|
+
function togglePopup(anchor, buildItems) {
|
|
374
|
+
if (activePopup?.parentElement === anchor) {
|
|
375
|
+
closePopup();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
openPopup(anchor, buildItems);
|
|
379
|
+
}
|
|
380
|
+
// autoClose=false for items that open sub-menus
|
|
381
|
+
function popupItem(label, active, onClick, iconHtml, value, autoClose = true) {
|
|
382
|
+
const item = document.createElement('button');
|
|
383
|
+
item.className = `pv-popup-item${active ? ' pv-active' : ''}`;
|
|
384
|
+
if (iconHtml) {
|
|
385
|
+
const iconSpan = document.createElement('span');
|
|
386
|
+
iconSpan.innerHTML = iconHtml;
|
|
387
|
+
iconSpan.style.lineHeight = '0';
|
|
388
|
+
item.appendChild(iconSpan);
|
|
389
|
+
}
|
|
390
|
+
const labelSpan = document.createElement('span');
|
|
391
|
+
labelSpan.className = 'pv-popup-label';
|
|
392
|
+
labelSpan.textContent = label;
|
|
393
|
+
item.appendChild(labelSpan);
|
|
394
|
+
if (value) {
|
|
395
|
+
const valSpan = document.createElement('span');
|
|
396
|
+
valSpan.className = 'pv-popup-value';
|
|
397
|
+
valSpan.textContent = value;
|
|
398
|
+
item.appendChild(valSpan);
|
|
399
|
+
}
|
|
400
|
+
item.addEventListener('click', (e) => {
|
|
401
|
+
e.stopPropagation();
|
|
402
|
+
onClick();
|
|
403
|
+
if (autoClose)
|
|
404
|
+
closePopup();
|
|
405
|
+
});
|
|
406
|
+
return item;
|
|
407
|
+
}
|
|
408
|
+
// --- Auto-hide ---
|
|
409
|
+
function resetHideTimer() {
|
|
410
|
+
overlay.classList.remove('pv-hidden');
|
|
411
|
+
clearTimeout(hideTimer);
|
|
412
|
+
hideTimer = setTimeout(() => {
|
|
413
|
+
if (!video.paused && !activePopup)
|
|
414
|
+
overlay.classList.add('pv-hidden');
|
|
415
|
+
}, 3000);
|
|
416
|
+
}
|
|
417
|
+
// --- Update functions ---
|
|
418
|
+
function updatePlayBtn() {
|
|
419
|
+
playBtn.innerHTML = video.paused ? ICON.play : ICON.pause;
|
|
420
|
+
}
|
|
421
|
+
function updateTime() {
|
|
422
|
+
if (seeking)
|
|
423
|
+
return;
|
|
424
|
+
seekBar.value = String(video.currentTime);
|
|
425
|
+
timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration || 0)}`;
|
|
426
|
+
updateBuffered();
|
|
427
|
+
}
|
|
428
|
+
function updateDuration() {
|
|
429
|
+
seekBar.max = String(video.duration || 0);
|
|
430
|
+
updateTime();
|
|
431
|
+
}
|
|
432
|
+
function updateVolume() {
|
|
433
|
+
volumeBar.value = String(video.muted ? 0 : video.volume);
|
|
434
|
+
volumeBtn.innerHTML = video.muted || video.volume === 0 ? ICON.volumeMuted : ICON.volumeHigh;
|
|
435
|
+
}
|
|
436
|
+
function updateBuffered() {
|
|
437
|
+
const duration = video.duration;
|
|
438
|
+
if (!duration) {
|
|
439
|
+
bufferedBar.style.width = '0';
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
let bufferedEnd = 0;
|
|
443
|
+
for (let i = 0; i < video.buffered.length; i++) {
|
|
444
|
+
if (video.buffered.start(i) <= video.currentTime) {
|
|
445
|
+
bufferedEnd = Math.max(bufferedEnd, video.buffered.end(i));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
bufferedBar.style.width = `${(bufferedEnd / duration) * 100}%`;
|
|
449
|
+
}
|
|
450
|
+
function updateFullscreenBtn() {
|
|
451
|
+
fsBtn.innerHTML = document.fullscreenElement ? ICON.fsExit : ICON.fsEnter;
|
|
452
|
+
}
|
|
453
|
+
// --- Video event listeners ---
|
|
454
|
+
const onPlay = () => {
|
|
455
|
+
updatePlayBtn();
|
|
456
|
+
resetHideTimer();
|
|
457
|
+
};
|
|
458
|
+
const onPause = () => {
|
|
459
|
+
updatePlayBtn();
|
|
460
|
+
overlay.classList.remove('pv-hidden');
|
|
461
|
+
clearTimeout(hideTimer);
|
|
462
|
+
};
|
|
463
|
+
const onTimeUpdate = () => updateTime();
|
|
464
|
+
const onDurationChange = () => updateDuration();
|
|
465
|
+
const onVolumeChange = () => updateVolume();
|
|
466
|
+
video.addEventListener('play', onPlay);
|
|
467
|
+
video.addEventListener('pause', onPause);
|
|
468
|
+
video.addEventListener('timeupdate', onTimeUpdate);
|
|
469
|
+
video.addEventListener('durationchange', onDurationChange);
|
|
470
|
+
video.addEventListener('loadedmetadata', onDurationChange);
|
|
471
|
+
video.addEventListener('volumechange', onVolumeChange);
|
|
472
|
+
const onProgress = () => updateBuffered();
|
|
473
|
+
video.addEventListener('progress', onProgress);
|
|
474
|
+
// Text track changes (for overflow menu state)
|
|
475
|
+
const onTrackChange = () => { }; // menu is rebuilt each open
|
|
476
|
+
video.textTracks.addEventListener('addtrack', onTrackChange);
|
|
477
|
+
video.textTracks.addEventListener('removetrack', onTrackChange);
|
|
478
|
+
// Legacy PiP events (for fallback path)
|
|
479
|
+
const onEnterPip = () => { };
|
|
480
|
+
const onLeavePip = () => { };
|
|
481
|
+
video.addEventListener('enterpictureinpicture', onEnterPip);
|
|
482
|
+
video.addEventListener('leavepictureinpicture', onLeavePip);
|
|
483
|
+
// --- Button handlers ---
|
|
484
|
+
// Play/pause — only via the center button
|
|
485
|
+
const onPlayClick = (e) => {
|
|
486
|
+
e.stopPropagation();
|
|
487
|
+
if (video.paused)
|
|
488
|
+
video.play();
|
|
489
|
+
else
|
|
490
|
+
video.pause();
|
|
491
|
+
};
|
|
492
|
+
playBtn.addEventListener('click', onPlayClick);
|
|
493
|
+
// Tap target: first tap always shows controls if hidden.
|
|
494
|
+
// When visible: touch hides controls, desktop click = play/pause.
|
|
495
|
+
const onTapTargetClick = (e) => {
|
|
496
|
+
e.stopPropagation();
|
|
497
|
+
if (activePopup) {
|
|
498
|
+
closePopup();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (overlay.classList.contains('pv-hidden')) {
|
|
502
|
+
// Always show controls first
|
|
503
|
+
resetHideTimer();
|
|
504
|
+
}
|
|
505
|
+
else if (isTouch) {
|
|
506
|
+
// Touch: hide controls
|
|
507
|
+
overlay.classList.add('pv-hidden');
|
|
508
|
+
clearTimeout(hideTimer);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
// Desktop: click on video = play/pause
|
|
512
|
+
if (video.paused)
|
|
513
|
+
video.play();
|
|
514
|
+
else
|
|
515
|
+
video.pause();
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
tapTarget.addEventListener('click', onTapTargetClick);
|
|
519
|
+
// Skip
|
|
520
|
+
const onSkipBack = (e) => {
|
|
521
|
+
e.stopPropagation();
|
|
522
|
+
video.currentTime = Math.max(0, video.currentTime - 10);
|
|
523
|
+
};
|
|
524
|
+
const onSkipFwd = (e) => {
|
|
525
|
+
e.stopPropagation();
|
|
526
|
+
video.currentTime = Math.min(video.duration || 0, video.currentTime + 10);
|
|
527
|
+
};
|
|
528
|
+
skipBackBtn.addEventListener('click', onSkipBack);
|
|
529
|
+
skipFwdBtn.addEventListener('click', onSkipFwd);
|
|
530
|
+
// Seek bar
|
|
531
|
+
const onSeekInput = () => {
|
|
532
|
+
seeking = true;
|
|
533
|
+
video.currentTime = Number(seekBar.value);
|
|
534
|
+
timeDisplay.textContent = `${formatTime(Number(seekBar.value))} / ${formatTime(video.duration || 0)}`;
|
|
535
|
+
};
|
|
536
|
+
const onSeekChange = () => {
|
|
537
|
+
video.currentTime = Number(seekBar.value);
|
|
538
|
+
seeking = false;
|
|
539
|
+
};
|
|
540
|
+
seekBar.addEventListener('input', onSeekInput);
|
|
541
|
+
seekBar.addEventListener('change', onSeekChange);
|
|
542
|
+
// Volume
|
|
543
|
+
const onVolumeBtnClick = () => {
|
|
544
|
+
video.muted = !video.muted;
|
|
545
|
+
};
|
|
546
|
+
const onVolumeInput = () => {
|
|
547
|
+
video.volume = Number(volumeBar.value);
|
|
548
|
+
if (Number(volumeBar.value) > 0)
|
|
549
|
+
video.muted = false;
|
|
550
|
+
};
|
|
551
|
+
volumeBtn.addEventListener('click', onVolumeBtnClick);
|
|
552
|
+
volumeBar.addEventListener('input', onVolumeInput);
|
|
553
|
+
// Fullscreen
|
|
554
|
+
const onFsClick = () => {
|
|
555
|
+
if (document.fullscreenElement) {
|
|
556
|
+
document.exitFullscreen();
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
container.requestFullscreen();
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
fsBtn.addEventListener('click', onFsClick);
|
|
563
|
+
const onFullscreenChange = () => updateFullscreenBtn();
|
|
564
|
+
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
565
|
+
// Overflow menu
|
|
566
|
+
const onOverflowClick = (e) => {
|
|
567
|
+
e.stopPropagation();
|
|
568
|
+
resetHideTimer();
|
|
569
|
+
togglePopup(overflowAnchor, () => {
|
|
570
|
+
const items = [];
|
|
571
|
+
// Captions — only show if tracks exist
|
|
572
|
+
const trackCount = video.textTracks.length;
|
|
573
|
+
if (trackCount > 0) {
|
|
574
|
+
let activeLang = 'Off';
|
|
575
|
+
for (let i = 0; i < trackCount; i++) {
|
|
576
|
+
if (video.textTracks[i].mode === 'showing') {
|
|
577
|
+
activeLang =
|
|
578
|
+
video.textTracks[i].label || video.textTracks[i].language || `Track ${i + 1}`;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
items.push(popupItem('Captions', false, () => {
|
|
582
|
+
openPopup(overflowAnchor, () => {
|
|
583
|
+
const subItems = [];
|
|
584
|
+
let anyShowing = false;
|
|
585
|
+
for (let i = 0; i < video.textTracks.length; i++) {
|
|
586
|
+
if (video.textTracks[i].mode === 'showing')
|
|
587
|
+
anyShowing = true;
|
|
588
|
+
}
|
|
589
|
+
subItems.push(popupItem('Off', !anyShowing, () => {
|
|
590
|
+
for (let i = 0; i < video.textTracks.length; i++) {
|
|
591
|
+
video.textTracks[i].mode = 'disabled';
|
|
592
|
+
}
|
|
593
|
+
}));
|
|
594
|
+
for (let i = 0; i < video.textTracks.length; i++) {
|
|
595
|
+
const track = video.textTracks[i];
|
|
596
|
+
const label = track.label || track.language || `Track ${i + 1}`;
|
|
597
|
+
subItems.push(popupItem(label, track.mode === 'showing', () => {
|
|
598
|
+
for (let j = 0; j < video.textTracks.length; j++) {
|
|
599
|
+
video.textTracks[j].mode = 'disabled';
|
|
600
|
+
}
|
|
601
|
+
track.mode = 'showing';
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
return subItems;
|
|
605
|
+
});
|
|
606
|
+
}, ICON.cc, activeLang, false));
|
|
607
|
+
}
|
|
608
|
+
// Playback speed
|
|
609
|
+
const rate = video.playbackRate;
|
|
610
|
+
items.push(popupItem('Playback speed', false, () => {
|
|
611
|
+
openPopup(overflowAnchor, () => SPEED_OPTIONS.map((r) => popupItem(`${r}x`, video.playbackRate === r, () => {
|
|
612
|
+
video.playbackRate = r;
|
|
613
|
+
})));
|
|
614
|
+
}, ICON.speed, `${rate === 1 ? 'Normal' : `${rate}x`}`, false));
|
|
615
|
+
// PiP
|
|
616
|
+
if (pipSupported) {
|
|
617
|
+
const inPip = pipWindow ? true : document.pictureInPictureElement === video;
|
|
618
|
+
items.push(popupItem('Picture in picture', inPip, async () => {
|
|
619
|
+
if (pipWindow) {
|
|
620
|
+
exitDocumentPip();
|
|
621
|
+
}
|
|
622
|
+
else if (docPipSupported) {
|
|
623
|
+
await enterDocumentPip();
|
|
624
|
+
}
|
|
625
|
+
else if (document.pictureInPictureElement === video) {
|
|
626
|
+
await document.exitPictureInPicture();
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
await video.requestPictureInPicture();
|
|
630
|
+
}
|
|
631
|
+
}, ICON.pip));
|
|
632
|
+
}
|
|
633
|
+
return items;
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
overflowBtn.addEventListener('click', onOverflowClick);
|
|
637
|
+
// Close popup on outside click
|
|
638
|
+
const onDocMouseDown = (e) => {
|
|
639
|
+
if (!activePopup)
|
|
640
|
+
return;
|
|
641
|
+
const target = e.target;
|
|
642
|
+
if (!activePopup.contains(target) && !overflowBtn.contains(target)) {
|
|
643
|
+
closePopup();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
document.addEventListener('mousedown', onDocMouseDown);
|
|
647
|
+
// Auto-hide on mouse movement (desktop only)
|
|
648
|
+
const onMouseMove = () => resetHideTimer();
|
|
649
|
+
container.addEventListener('mousemove', onMouseMove);
|
|
650
|
+
// Init state
|
|
651
|
+
updatePlayBtn();
|
|
652
|
+
updateDuration();
|
|
653
|
+
updateVolume();
|
|
654
|
+
resetHideTimer();
|
|
655
|
+
return {
|
|
656
|
+
destroy() {
|
|
657
|
+
exitDocumentPip();
|
|
658
|
+
clearTimeout(hideTimer);
|
|
659
|
+
closePopup();
|
|
660
|
+
video.removeEventListener('play', onPlay);
|
|
661
|
+
video.removeEventListener('pause', onPause);
|
|
662
|
+
video.removeEventListener('timeupdate', onTimeUpdate);
|
|
663
|
+
video.removeEventListener('durationchange', onDurationChange);
|
|
664
|
+
video.removeEventListener('loadedmetadata', onDurationChange);
|
|
665
|
+
video.removeEventListener('volumechange', onVolumeChange);
|
|
666
|
+
video.removeEventListener('progress', onProgress);
|
|
667
|
+
video.removeEventListener('enterpictureinpicture', onEnterPip);
|
|
668
|
+
video.removeEventListener('leavepictureinpicture', onLeavePip);
|
|
669
|
+
video.textTracks.removeEventListener('addtrack', onTrackChange);
|
|
670
|
+
video.textTracks.removeEventListener('removetrack', onTrackChange);
|
|
671
|
+
playBtn.removeEventListener('click', onPlayClick);
|
|
672
|
+
tapTarget.removeEventListener('click', onTapTargetClick);
|
|
673
|
+
skipBackBtn.removeEventListener('click', onSkipBack);
|
|
674
|
+
skipFwdBtn.removeEventListener('click', onSkipFwd);
|
|
675
|
+
seekBar.removeEventListener('input', onSeekInput);
|
|
676
|
+
seekBar.removeEventListener('change', onSeekChange);
|
|
677
|
+
volumeBtn.removeEventListener('click', onVolumeBtnClick);
|
|
678
|
+
volumeBar.removeEventListener('input', onVolumeInput);
|
|
679
|
+
fsBtn.removeEventListener('click', onFsClick);
|
|
680
|
+
overflowBtn.removeEventListener('click', onOverflowClick);
|
|
681
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
682
|
+
document.removeEventListener('mousedown', onDocMouseDown);
|
|
683
|
+
container.removeEventListener('mousemove', onMouseMove);
|
|
684
|
+
overlay.remove();
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=custom-controls.js.map
|