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.
@@ -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