vyrplay 1.0.0 → 1.0.1

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/src/VyrPlay.js DELETED
@@ -1,1331 +0,0 @@
1
- /**
2
- * VyrPlay — lightweight, robust, responsive custom video player.
3
- * Supports MP4, HLS, multi-quality with smart fallback.
4
- *
5
- * Usage:
6
- * import { VyrPlay } from 'vyrplay';
7
- * const player = new VyrPlay('#el', { sources: [...], title: '...' });
8
- */
9
-
10
- import { MediaEngine } from './engine/MediaEngine.js';
11
- import { Emitter } from './utils/events.js';
12
- import { fmtTime, fmtRate, clamp } from './utils/format.js';
13
- import { buildTemplate } from './ui/template.js';
14
-
15
- const SEEK_STEPS = [5, 10, 15, 30, 60];
16
- const SPEED_OPTS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
17
- const HOLD_SPEED_OPTS = [1.5, 1.75, 2, 2.5, 3];
18
- const LS = {
19
- seek: 'vyrplay_seek_step',
20
- rate: 'vyrplay_rate',
21
- holdOn: 'vyrplay_hold_speed',
22
- holdRate: 'vyrplay_hold_rate',
23
- vol: 'vyrplay_volume',
24
- muted: 'vyrplay_muted',
25
- subY: 'vyrplay_sub_y',
26
- subOn: 'vyrplay_sub_on',
27
- subColor: 'vyrplay_sub_color',
28
- subSize: 'vyrplay_sub_size',
29
- subBg: 'vyrplay_sub_bg',
30
- subLang: 'vyrplay_sub_lang',
31
- };
32
-
33
- function $(root, sel) {
34
- if (!root) return null;
35
- if (typeof root === "string") return document.querySelector(root);
36
- return root.querySelector(sel);
37
- }
38
- function $$(root, sel) {
39
- if (!root) return [];
40
- return Array.from(root.querySelectorAll(sel));
41
- }
42
-
43
- export class VyrPlay {
44
- /**
45
- * @param {string|HTMLElement} container
46
- * @param {object} options
47
- */
48
- constructor(container, options = {}) {
49
- this.el = typeof container === 'string' ? document.querySelector(container) : container;
50
- if (!this.el) throw new Error('[VyrPlay] Container not found');
51
-
52
- this.opts = this._mergeOpts(options);
53
- this.emitter = new Emitter();
54
- if (this.opts.on) {
55
- Object.entries(this.opts.on).forEach(([ev, fn]) => {
56
- if (typeof fn === 'function') this.emitter.on(ev, fn);
57
- });
58
- }
59
-
60
- this.uiVisible = true;
61
- this.uiLocked = false;
62
- this.seeking = false;
63
- this.hideTimer = null;
64
- this.seekStep = this._loadNum(LS.seek, this.opts.settings.seekStep, 1, 300);
65
- this.playRate = this._loadNum(LS.rate, this.opts.settings.playbackRate, 0.25, 3);
66
- this.holdSpeedOn = this._loadBool(LS.holdOn, this.opts.settings.holdSpeedEnabled);
67
- this.holdRate = this._loadNum(LS.holdRate, this.opts.settings.holdSpeed, 1, 4);
68
- this.volLevel = this._loadNum(LS.vol, 1, 0, 1);
69
- this.volMuted = this._loadBool(LS.muted, false);
70
- this.subY = this._loadNum(LS.subY, 12, 0, 80);
71
- this.subOn = this._loadBool(LS.subOn, true);
72
- this.subColor = (typeof localStorage !== 'undefined' && localStorage.getItem(LS.subColor)) || '#ffffff';
73
- this.subSize = this._loadNum(LS.subSize, 20, 14, 42);
74
- this.subBg = this._loadNum(LS.subBg, 55, 0, 100);
75
- this.subLang = (typeof localStorage !== 'undefined' && localStorage.getItem(LS.subLang)) || '';
76
- this._tracks = [];
77
- this._activeTrackIdx = -1;
78
- this.isHoldingSpeed = false;
79
- this.holdTimer = null;
80
- this._destroyed = false;
81
-
82
- this._injectStyles();
83
- this._buildDOM();
84
- this._bindRefs();
85
- this.audioEl = null;
86
- this._audioUrl = null;
87
- this._bindUI();
88
- this._applySettingsUI();
89
- this._applySubStyle();
90
-
91
- this.engine = new MediaEngine(this.video, {
92
- onReady: (info) => {
93
- this._hideLoading();
94
- this._hideBuffer();
95
- this._updatePlayIcon();
96
- this._updateProgress();
97
- this._renderQualities();
98
- this._showUI();
99
- if (this.opts.tracks && this.opts.tracks.length) {
100
- this._setupTracks(this.opts.tracks);
101
- }
102
- this.emitter.emit('ready', this.getState());
103
- },
104
- onError: (err) => {
105
- this._showError(err.message || 'Gagal memuat video');
106
- this.emitter.emit('error', err);
107
- },
108
- onQualityChange: (label) => {
109
- if (this.btnQuality) this.btnQuality.textContent = label || 'HD';
110
- this._renderQualities();
111
- },
112
- });
113
-
114
- // Auto start if sources provided
115
- if (this.opts.sources || this.opts.src) {
116
- this.load(this.opts);
117
- } else {
118
- this._hideLoading();
119
- }
120
- }
121
-
122
- _mergeOpts(o) {
123
- return {
124
- title: o.title || 'VyrPlay',
125
- poster: o.poster || null,
126
- startTime: o.startTime || 0,
127
- sources: o.sources || null,
128
- src: o.src || null,
129
- type: o.type || null,
130
- audio: o.audio || null,
131
- features: {
132
- quality: true,
133
- pip: true,
134
- lockUi: true,
135
- holdToSpeed: true,
136
- volume: true,
137
- seekButtons: true,
138
- settings: true,
139
- subtitles: true,
140
- back: false,
141
- ...o.features,
142
- },
143
- settings: {
144
- seekStep: 10,
145
- playbackRate: 1,
146
- holdSpeed: 2,
147
- holdSpeedEnabled: true,
148
- ...o.settings,
149
- },
150
- on: o.on || {},
151
- ...o,
152
- };
153
- }
154
-
155
- _loadNum(key, def, min, max) {
156
- try {
157
- const v = parseFloat(localStorage.getItem(key));
158
- if (isFinite(v) && v >= min && v <= max) return v;
159
- } catch (_) {}
160
- return def;
161
- }
162
- _loadBool(key, def) {
163
- try {
164
- const v = localStorage.getItem(key);
165
- if (v === '0' || v === 'false') return false;
166
- if (v === '1' || v === 'true') return true;
167
- } catch (_) {}
168
- return def;
169
- }
170
- _save(key, val) {
171
- try { localStorage.setItem(key, String(val)); } catch (_) {}
172
- }
173
-
174
- _injectStyles() {
175
- if (document.getElementById('vyrplay-styles')) return;
176
- // Styles are expected via import or link; for standalone we inject a minimal marker
177
- // Full CSS should be imported by user: import 'vyrplay/style.css'
178
- // For demo we also allow inline injection from build
179
- }
180
-
181
- _buildDOM() {
182
- // Parent harus relative supaya absolute root mengisi area
183
- try {
184
- const parent = this.el.parentElement;
185
- if (parent) {
186
- const ps = window.getComputedStyle(parent);
187
- if (ps.position === 'static') parent.style.position = 'relative';
188
- }
189
- } catch (_) {}
190
- this.el.classList.add('vyr-root');
191
- // Pastikan host punya ukuran minimal jika parent belum set height
192
- try {
193
- const cs = window.getComputedStyle(this.el);
194
- if ((!cs.height || cs.height === '0px') && !this.el.style.height) {
195
- this.el.style.width = this.el.style.width || '100%';
196
- this.el.style.aspectRatio = this.el.style.aspectRatio || '16 / 9';
197
- }
198
- } catch (_) {}
199
- this.el.innerHTML = buildTemplate({
200
- title: this.opts.title,
201
- features: this.opts.features,
202
- });
203
- if (this.opts.poster) {
204
- const p = this.el.querySelector('.vyr-poster');
205
- if (p) {
206
- p.style.backgroundImage = `url("${this.opts.poster}")`;
207
- p.classList.remove('hide');
208
- }
209
- }
210
- }
211
-
212
- _bindRefs() {
213
- const r = this.el;
214
- this.video = $(r, 'video');
215
- this.ui = $(r, '.vyr-ui');
216
- this.loading = $(r, '.vyr-loading');
217
- this.buffer = $(r, '.vyr-buffer');
218
- this.poster = $(r, '.vyr-poster');
219
- this.btnPP = $(r, '.vyr-btn-pp');
220
- this.btnRew = $(r, '.vyr-btn-rew');
221
- this.btnFf = $(r, '.vyr-btn-ff');
222
- this.btnQuality = $(r, '.vyr-btn-quality');
223
- this.btnFs = $(r, '.vyr-btn-fs');
224
- this.btnLock = $(r, '.vyr-btn-lock');
225
- this.btnSettings = $(r, '.vyr-btn-settings');
226
- this.btnPip = $(r, '.vyr-btn-pip');
227
- this.btnBack = $(r, '.vyr-btn-back');
228
- this.progress = $(r, '.vyr-progress');
229
- this.pFill = $(r, '.vyr-progress-fill');
230
- this.pThumb = $(r, '.vyr-progress-thumb');
231
- this.timeLabel = $(r, '.vyr-time');
232
- this.scrubTip = $(r, '.vyr-scrub-tip');
233
- this.titleEl = $(r, '.vyr-title');
234
- this.speedBadge = $(r, '.vyr-speed-badge');
235
- this.volToast = $(r, '.vyr-vol-toast');
236
- this.backdrop = $(r, '.vyr-backdrop');
237
- this.sheetQuality = $(r, '.vyr-sheet-quality');
238
- this.sheetSettings = $(r, '.vyr-sheet-settings');
239
- this.sheetSeek = $(r, '.vyr-sheet-seek');
240
- this.sheetSpeed = $(r, '.vyr-sheet-speed');
241
- this.sheetHold = $(r, '.vyr-sheet-hold');
242
- this.qualityList = $(r, '.vyr-quality-list');
243
- this.seekList = $(r, '.vyr-seek-list');
244
- this.speedList = $(r, '.vyr-speed-list');
245
- this.holdList = $(r, '.vyr-hold-list');
246
- this.pipOverlay = $(r, '.vyr-pip-overlay');
247
- this.subLayer = $(r, '.vyr-sub-layer');
248
- this.subText = $(r, '.vyr-sub-text');
249
- this.btnCc = $(r, '.vyr-btn-cc');
250
- this.sheetSubs = $(r, '.vyr-sheet-subs');
251
- this.sheetSubstyle = $(r, '.vyr-sheet-substyle');
252
- this.subsList = $(r, '.vyr-subs-list');
253
- }
254
-
255
- // ─── Public API ───────────────────────────────────────────
256
-
257
- /** Load new sources / options */
258
- async load(opts = {}) {
259
- if (this._destroyed) return;
260
- this._showLoading();
261
- if (opts.title && this.titleEl) this.titleEl.textContent = opts.title;
262
- if (opts.poster && this.poster) {
263
- this.poster.style.backgroundImage = `url("${opts.poster}")`;
264
- this.poster.classList.remove('hide');
265
- }
266
- const input = opts.sources || (opts.src ? [{ url: opts.src, type: opts.type }] : this.opts.sources || this.opts.src);
267
- this.engine.setSources(input);
268
- const start = opts.startTime ?? this.opts.startTime ?? 0;
269
- if (opts.tracks) {
270
- this.opts.tracks = opts.tracks;
271
- }
272
- if (opts.audio !== undefined) {
273
- this.opts.audio = opts.audio;
274
- }
275
- await this._setupAudio(this.opts.audio || null);
276
- await this.engine.load(start, true);
277
- // tracks juga di onReady; panggil lagi di sini agar custom URL pasti ke-load
278
- if (this.opts.tracks && this.opts.tracks.length) {
279
- await this._setupTracks(this.opts.tracks);
280
- }
281
- }
282
-
283
- play() {
284
- const p = this.video.play();
285
- this._audioPlay();
286
- return p;
287
- }
288
- pause() {
289
- this.video.pause();
290
- this._audioPause();
291
- }
292
- toggle() {
293
- if (this.video.paused) this.play().catch(() => {});
294
- else this.pause();
295
- }
296
- seek(sec) {
297
- this._safeSeek(sec);
298
- }
299
- setQuality(label) {
300
- return this.engine.switchQuality(label);
301
- }
302
- setPlaybackRate(r) {
303
- this.playRate = r;
304
- this._save(LS.rate, r);
305
- if (!this.isHoldingSpeed) {
306
- try { this.video.playbackRate = r; } catch (_) {}
307
- try { if (this.audioEl) this.audioEl.playbackRate = r; } catch (_) {}
308
- }
309
- const el = this.el.querySelector('.vyr-set-speed-val');
310
- if (el) el.textContent = fmtRate(r);
311
- this._renderSpeedList();
312
- }
313
- getState() {
314
- return {
315
- currentTime: this.video?.currentTime || 0,
316
- duration: (this.video && isFinite(this.video.duration)) ? this.video.duration : 0,
317
- paused: !this.video || this.video.paused,
318
- quality: this.engine?.currentQuality,
319
- playbackRate: this.playRate,
320
- volume: this.volLevel,
321
- muted: this.volMuted,
322
- subtitle: this._activeTrackIdx >= 0 ? (this._tracks[this._activeTrackIdx]?.label || this.subLang) : null,
323
- };
324
- }
325
- on(event, fn) { return this.emitter.on(event, fn); }
326
- off(event, fn) { this.emitter.off(event, fn); }
327
-
328
-
329
-
330
- /** Audio terpisah (CDN yang video/audio stream beda URL) */
331
- async _setupAudio(url) {
332
- if (this._audioHls) {
333
- try { this._audioHls.destroy(); } catch (_) {}
334
- this._audioHls = null;
335
- }
336
- if (this.audioEl) {
337
- try { this.audioEl.pause(); } catch (_) {}
338
- try { this.audioEl.removeAttribute('src'); this.audioEl.load(); } catch (_) {}
339
- try { this.audioEl.remove(); } catch (_) {}
340
- this.audioEl = null;
341
- }
342
- this._audioUrl = url || null;
343
- if (!url) {
344
- try {
345
- this.video.muted = !!this.volMuted;
346
- this.video.volume = this.volMuted ? 0 : this.volLevel;
347
- } catch (_) {}
348
- this._syncAudioTime = null;
349
- return;
350
- }
351
- const clean = String(url).split('#')[0];
352
- const a = document.createElement('audio');
353
- a.preload = 'auto';
354
- a.setAttribute('playsinline', '');
355
- a.style.display = 'none';
356
- this.el.appendChild(a);
357
- this.audioEl = a;
358
- try {
359
- this.video.muted = true;
360
- a.volume = this.volMuted ? 0 : this.volLevel;
361
- a.muted = !!this.volMuted;
362
- } catch (_) {}
363
-
364
- const isHls = /\.m3u8(\?|$)/i.test(clean);
365
- if (isHls && a.canPlayType('application/vnd.apple.mpegurl')) {
366
- a.src = clean;
367
- } else if (isHls) {
368
- try {
369
- let Hls = window.Hls;
370
- if (!Hls) {
371
- await new Promise((resolve, reject) => {
372
- const s = document.createElement('script');
373
- s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.7';
374
- s.onload = () => resolve();
375
- s.onerror = reject;
376
- document.head.appendChild(s);
377
- });
378
- Hls = window.Hls;
379
- }
380
- if (Hls && Hls.isSupported()) {
381
- this._audioHls = new Hls({ enableWorker: true, maxBufferLength: 30 });
382
- this._audioHls.loadSource(clean);
383
- this._audioHls.attachMedia(a);
384
- } else {
385
- a.src = clean;
386
- }
387
- } catch (err) {
388
- console.warn('[VyrPlay] audio hls failed', err);
389
- a.src = clean;
390
- }
391
- } else {
392
- a.src = clean;
393
- }
394
-
395
- this._syncAudioTime = () => {
396
- if (!this.audioEl || !this.video) return;
397
- try {
398
- const drift = Math.abs((this.audioEl.currentTime || 0) - (this.video.currentTime || 0));
399
- if (drift > 0.35) this.audioEl.currentTime = this.video.currentTime || 0;
400
- } catch (_) {}
401
- };
402
- a.addEventListener('error', () => console.warn('[VyrPlay] audio track failed', url));
403
- }
404
-
405
- _audioPlay() {
406
- if (!this.audioEl) return;
407
- try {
408
- this.audioEl.currentTime = this.video.currentTime || 0;
409
- this.audioEl.playbackRate = this.video.playbackRate || 1;
410
- this.audioEl.play().catch(() => {});
411
- } catch (_) {}
412
- }
413
-
414
- _audioPause() {
415
- if (!this.audioEl) return;
416
- try { this.audioEl.pause(); } catch (_) {}
417
- }
418
-
419
- // ─── Subtitles ──────────────────────────────────────────
420
-
421
- /** @param {Array<{src,label,srclang,kind,default}>} tracks */
422
- setTracks(tracks) {
423
- this._setupTracks(tracks || []);
424
- }
425
-
426
- async _setupTracks(tracks) {
427
- this._tracks = Array.isArray(tracks) ? tracks.map((t) => ({ ...t })) : [];
428
- (this._vttBlobs || []).forEach((u) => { try { URL.revokeObjectURL(u); } catch (_) {} });
429
- this._vttBlobs = [];
430
- if (this.video) {
431
- Array.from(this.video.querySelectorAll('track')).forEach((t) => t.remove());
432
- try {
433
- for (let i = 0; i < this.video.textTracks.length; i++) {
434
- this.video.textTracks[i].mode = 'disabled';
435
- }
436
- } catch (_) {}
437
- }
438
- this._activeTrackIdx = -1;
439
- if (this.subText) this.subText.textContent = '';
440
- if (!this._tracks.length) {
441
- if (this.btnCc) this.btnCc.style.display = 'none';
442
- this._applySubStyle();
443
- return;
444
- }
445
- if (this.btnCc) this.btnCc.style.display = '';
446
-
447
- // Fetch + parse setiap track (multi-sub)
448
- for (let i = 0; i < this._tracks.length; i++) {
449
- const t = this._tracks[i];
450
- let text = null;
451
- try {
452
- const res = await fetch(t.src, { mode: 'cors', credentials: 'omit' });
453
- if (res.ok) text = await res.text();
454
- } catch (err) {
455
- console.warn('[VyrPlay] VTT fetch CORS failed', t.src, err);
456
- }
457
- // fallback no-cors tidak bisa baca body — coba sekali lagi tanpa credentials
458
- if (!text) {
459
- try {
460
- const res2 = await fetch(t.src);
461
- if (res2.ok) text = await res2.text();
462
- } catch (_) {}
463
- }
464
- if (text && text.trim()) {
465
- this._tracks[i]._cues = this._parseVtt(text);
466
- console.log('[VyrPlay] sub track', t.label || t.srclang || i, 'cues=', this._tracks[i]._cues.length);
467
- } else {
468
- this._tracks[i]._cues = [];
469
- console.warn('[VyrPlay] VTT empty/failed', t.src);
470
- }
471
- }
472
-
473
- // Aktifkan default: prefer default flag, else first track with cues
474
- let idx = this._tracks.findIndex((t) => t.default);
475
- if (idx < 0) idx = this._tracks.findIndex((t) => t._cues && t._cues.length);
476
- if (idx < 0) idx = 0;
477
- this.subOn = true;
478
- this._save(LS.subOn, '1');
479
- this._enableTrack(idx);
480
- this._renderSubsList();
481
- this._applySubStyle();
482
- this._syncManualSubs();
483
- }
484
-
485
- _enableTrack(idx) {
486
- if (idx < 0 || idx >= this._tracks.length) {
487
- this._disableAllTracks();
488
- return;
489
- }
490
- this._activeTrackIdx = idx;
491
- this.subOn = true;
492
- this._save(LS.subOn, '1');
493
- const t = this._tracks[idx];
494
- if (t) {
495
- this.subLang = t.srclang || t.label || '';
496
- this._save(LS.subLang, this.subLang);
497
- }
498
- this.btnCc?.classList.add('active');
499
- this._applySubStyle();
500
- this._renderSubsList();
501
- this._syncManualSubs();
502
- }
503
-
504
- _disableAllTracks() {
505
- this._activeTrackIdx = -1;
506
- try {
507
- for (let i = 0; i < this.video.textTracks.length; i++) {
508
- this.video.textTracks[i].mode = 'disabled';
509
- }
510
- } catch (_) {}
511
- if (this.subText) this.subText.textContent = '';
512
- this.btnCc?.classList.remove('active');
513
- this._save(LS.subLang, '');
514
- this.subLang = '';
515
- this._applySubStyle();
516
- this._renderSubsList();
517
- }
518
-
519
-
520
- _parseVtt(text) {
521
- const cues = [];
522
- if (!text) return cues;
523
- const blocks = String(text).replace(/\r/g, '').split(/\n\n+/);
524
- const ts = (s) => {
525
- const m = String(s || '').trim().replace(',', '.').match(/(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?/);
526
- if (!m) return 0;
527
- const h = parseInt(m[1] || '0', 10);
528
- const mi = parseInt(m[2], 10);
529
- const sec = parseInt(m[3], 10);
530
- const ms = parseInt((m[4] || '0').padEnd(3, '0'), 10);
531
- return h * 3600 + mi * 60 + sec + ms / 1000;
532
- };
533
- for (const block of blocks) {
534
- const lines = block.split('\n').filter((l) => l.trim() !== '');
535
- if (!lines.length || /^WEBVTT/i.test(lines[0]) || /^NOTE/.test(lines[0])) continue;
536
- let i = 0;
537
- if (lines[0].includes('-->')) i = 0;
538
- else if (lines[1] && lines[1].includes('-->')) i = 1;
539
- else continue;
540
- const parts = lines[i].split('-->');
541
- if (parts.length < 2) continue;
542
- const start = ts(parts[0]);
543
- const endRaw = (parts[1] || '').trim().split(/\s+/)[0];
544
- const end = ts(endRaw);
545
- const body = lines.slice(i + 1).join('\n').replace(/<[^>]+>/g, '');
546
- if (body) cues.push({ start, end, text: body });
547
- }
548
- return cues;
549
- }
550
-
551
- _syncManualSubs() {
552
- if (!this.subOn || this._activeTrackIdx < 0 || !this.subText) {
553
- if (this.subText && (!this.subOn || this._activeTrackIdx < 0)) this.subText.textContent = '';
554
- return;
555
- }
556
- const tr = this._tracks[this._activeTrackIdx];
557
- const cues = tr && tr._cues;
558
- if (!cues || !cues.length) return;
559
- const now = this.video ? (this.video.currentTime || 0) : 0;
560
- let text = '';
561
- for (let i = 0; i < cues.length; i++) {
562
- const cu = cues[i];
563
- // toleransi 80ms di akhir biar tidak kedip hilang terlalu cepat
564
- if (now >= cu.start && now <= cu.end + 0.08) {
565
- text = text ? (text + '\n' + cu.text) : cu.text;
566
- }
567
- }
568
- if (this.subText.textContent !== text) this.subText.textContent = text;
569
- }
570
-
571
- _onCueChange(tt) {
572
- // Selalu prioritaskan overlay manual jika VTT sudah di-parse
573
- // (TextTrack browser sering mismatch timing di HLS)
574
- const tr = this._tracks[this._activeTrackIdx];
575
- if (tr && tr._cues && tr._cues.length) {
576
- this._syncManualSubs();
577
- return;
578
- }
579
- if (this._activeTrackIdx < 0) {
580
- if (this.subText) this.subText.textContent = '';
581
- return;
582
- }
583
- try {
584
- const active = this.video.textTracks[this._activeTrackIdx];
585
- if (tt && active && tt !== active) return;
586
- const cues = (active && active.activeCues) ? active.activeCues : null;
587
- if (!cues || !cues.length) {
588
- if (this.subText) this.subText.textContent = '';
589
- return;
590
- }
591
- const parts = [];
592
- for (let i = 0; i < cues.length; i++) parts.push(cues[i].text);
593
- if (this.subText) this.subText.textContent = parts.join('\n');
594
- } catch (_) {
595
- if (this.subText) this.subText.textContent = '';
596
- }
597
- }
598
-
599
- _applySubStyle() {
600
- if (!this.subLayer || !this.subText) return;
601
- this.el.style.setProperty('--vyr-sub-y', String(this.subY));
602
- this.subLayer.style.display = this.subOn && this._activeTrackIdx >= 0 ? 'flex' : 'none';
603
- this.subText.style.color = this.subColor;
604
- this.subText.style.fontSize = this.subSize + 'px';
605
- this.subText.style.fontFamily = 'var(--vyr-font)';
606
- this.subText.style.background = 'rgba(0,0,0,' + (this.subBg / 100).toFixed(2) + ')';
607
- const color = this.el.querySelector('.vyr-sub-color');
608
- const size = this.el.querySelector('.vyr-sub-size');
609
- const bg = this.el.querySelector('.vyr-sub-bg');
610
- const y = this.el.querySelector('.vyr-sub-y');
611
- const sizeVal = this.el.querySelector('.vyr-sub-size-val');
612
- const bgVal = this.el.querySelector('.vyr-sub-bg-val');
613
- const yVal = this.el.querySelector('.vyr-sub-y-val');
614
- const tog = this.el.querySelector('[data-tog-sub]');
615
- if (color) color.value = this.subColor;
616
- if (size) size.value = String(this.subSize);
617
- if (bg) bg.value = String(this.subBg);
618
- if (y) y.value = String(this.subY);
619
- if (sizeVal) sizeVal.textContent = this.subSize + 'px';
620
- if (bgVal) bgVal.textContent = this.subBg + '%';
621
- if (yVal) yVal.textContent = this.subY + '%';
622
- if (tog) tog.classList.toggle('on', this.subOn);
623
- }
624
-
625
- _renderSubsList() {
626
- if (!this.subsList) return;
627
- let html = '<div class="vyr-sheet-item' + (this._activeTrackIdx < 0 ? ' active' : '') + '" data-sub-idx="-1">Off<span class="check">✓</span></div>';
628
- this._tracks.forEach((t, i) => {
629
- const label = t.label || t.srclang || ('Track ' + (i + 1));
630
- html += '<div class="vyr-sheet-item' + (i === this._activeTrackIdx ? ' active' : '') + '" data-sub-idx="' + i + '">' + label + '<span class="check">✓</span></div>';
631
- });
632
- this.subsList.innerHTML = html;
633
- }
634
-
635
-
636
- destroy() {
637
- this._destroyed = true;
638
- clearTimeout(this.hideTimer);
639
- clearTimeout(this.holdTimer);
640
- (this._vttBlobs || []).forEach((u) => { try { URL.revokeObjectURL(u); } catch (_) {} });
641
- this._vttBlobs = [];
642
- this.engine?.destroy();
643
- this.emitter.destroy();
644
- this.el.innerHTML = '';
645
- this.el.classList.remove('vyr-root', 'is-fs');
646
- }
647
-
648
- // ─── Internal UI ──────────────────────────────────────────
649
-
650
- _showLoading() { this.loading?.classList.remove('hide'); }
651
- _hideLoading() { this.loading?.classList.add('hide'); }
652
- _showBuffer() {
653
- this.buffer?.classList.add('show');
654
- this.btnPP?.classList.add('loading');
655
- }
656
- _hideBuffer() {
657
- this.buffer?.classList.remove('show');
658
- this.btnPP?.classList.remove('loading');
659
- }
660
-
661
- _showError(msg) {
662
- if (!this.loading) return;
663
- this.loading.innerHTML = `
664
- <div class="vyr-error">
665
- <div class="vyr-error-title">Gagal memuat</div>
666
- <div class="vyr-error-detail">${msg}</div>
667
- </div>`;
668
- this.loading.classList.remove('hide');
669
- }
670
-
671
- _updatePlayIcon() {
672
- const paused = this.video.paused;
673
- const play = this.el.querySelector('.vyr-ic-play');
674
- const pause = this.el.querySelector('.vyr-ic-pause');
675
- if (play) play.style.display = paused ? 'block' : 'none';
676
- if (pause) pause.style.display = paused ? 'none' : 'block';
677
- }
678
-
679
- _paintProgress(t, d, showTip) {
680
- d = (d != null && isFinite(d)) ? d : (this.video?.duration || 0);
681
- t = isFinite(t) ? Math.max(0, t) : 0;
682
- if (d > 0) t = Math.min(t, d);
683
- const pct = d > 0 ? (t / d) * 100 : 0;
684
- if (this.pFill) this.pFill.style.width = pct + '%';
685
- if (this.pThumb) this.pThumb.style.left = pct + '%';
686
- if (this.timeLabel) this.timeLabel.textContent = fmtTime(t) + ' / ' + fmtTime(d);
687
- if (this.scrubTip) {
688
- this.scrubTip.textContent = fmtTime(t);
689
- this.scrubTip.style.left = pct + '%';
690
- this.scrubTip.classList.toggle('show', !!showTip);
691
- }
692
- }
693
-
694
- _updateProgress() {
695
- if (this.seeking) return;
696
- this._paintProgress(this.video.currentTime || 0, this.video.duration || 0, false);
697
- }
698
-
699
- _safeSeek(sec) {
700
- if (!this.video || !isFinite(sec)) return;
701
- const d = this.video.duration;
702
- if (isFinite(d) && d > 0) sec = clamp(sec, 0, Math.max(0, d - 0.3));
703
- else sec = Math.max(0, sec);
704
- this._paintProgress(sec, d, false);
705
- const wasPlaying = !this.video.paused;
706
- this.btnPP?.classList.add('loading');
707
- let done = false;
708
- const finish = () => {
709
- if (done) return;
710
- done = true;
711
- this.btnPP?.classList.remove('loading');
712
- if (wasPlaying) this.video.play().catch(() => {});
713
- if (!this.seeking) this._updateProgress();
714
- };
715
- this.video.addEventListener('seeked', finish, { once: true });
716
- this.video.addEventListener('canplay', finish, { once: true });
717
- setTimeout(finish, 4000);
718
- try { this.video.currentTime = sec; } catch (_) { finish(); }
719
- if (this.audioEl) {
720
- try { this.audioEl.currentTime = sec; } catch (_) {}
721
- }
722
- }
723
-
724
- _showUI() {
725
- if (this.uiLocked) return;
726
- this.uiVisible = true;
727
- this.ui?.classList.remove('hidden');
728
- clearTimeout(this.hideTimer);
729
- if (this.seeking || this._anySheetOpen()) return;
730
- this.hideTimer = setTimeout(() => {
731
- if (!this._anySheetOpen() && !this.seeking) this._hideUI();
732
- }, 3500);
733
- }
734
- _hideUI() {
735
- if (this.uiLocked || this.seeking) return;
736
- // sheet terbuka: jangan auto-close di sini; user tutup via X / backdrop
737
- if (this._anySheetOpen()) return;
738
- this.uiVisible = false;
739
- this.ui?.classList.add('hidden');
740
- clearTimeout(this.hideTimer);
741
- }
742
- _toggleUI() {
743
- if (this.uiLocked) return;
744
- if (this.uiVisible) this._hideUI();
745
- else this._showUI();
746
- }
747
- _anySheetOpen() {
748
- return !!this.el.querySelector('.vyr-sheet.show');
749
- }
750
- _closeSheets() {
751
- $$(this.el, '.vyr-sheet').forEach((s) => s.classList.remove('show'));
752
- this.backdrop?.classList.remove('show');
753
- }
754
- _openSheet(sheet) {
755
- if (!sheet || this.uiLocked) return;
756
- this._closeSheets();
757
- sheet.classList.add('show');
758
- this.backdrop?.classList.add('show');
759
- this._showUI();
760
- }
761
-
762
- _applySettingsUI() {
763
- const seekVal = this.el.querySelector('.vyr-set-seek-val');
764
- if (seekVal) seekVal.textContent = this.seekStep + 's';
765
- const speedVal = this.el.querySelector('.vyr-set-speed-val');
766
- if (speedVal) speedVal.textContent = fmtRate(this.playRate);
767
- const holdVal = this.el.querySelector('.vyr-set-hold-val');
768
- if (holdVal) holdVal.textContent = fmtRate(this.holdRate);
769
- const br = this.el.querySelector('.vyr-badge-rew');
770
- const bf = this.el.querySelector('.vyr-badge-ff');
771
- if (br) br.textContent = String(this.seekStep);
772
- if (bf) bf.textContent = String(this.seekStep);
773
- try { this.video.playbackRate = this.playRate; } catch (_) {}
774
- this._applyVolume(false);
775
- }
776
-
777
- _renderQualities() {
778
- if (!this.qualityList || !this.btnQuality) return;
779
- const qs = this.engine.getQualities();
780
- if (!qs.length) {
781
- this.btnQuality.style.display = 'none';
782
- return;
783
- }
784
- this.btnQuality.style.display = '';
785
- this.btnQuality.textContent = this.engine.currentQuality || qs[0].label;
786
- this.qualityList.innerHTML = qs.map((q) =>
787
- `<div class="vyr-sheet-item${q.label === this.engine.currentQuality ? ' active' : ''}" data-label="${q.label}">${q.label}<span class="check">✓</span></div>`
788
- ).join('');
789
- }
790
-
791
- _renderSeekList() {
792
- if (!this.seekList) return;
793
- this.seekList.innerHTML = SEEK_STEPS.map((n) =>
794
- `<div class="vyr-sheet-item${n === this.seekStep ? ' active' : ''}" data-step="${n}">${n} detik<span class="check">✓</span></div>`
795
- ).join('');
796
- }
797
- _renderSpeedList() {
798
- if (!this.speedList) return;
799
- this.speedList.innerHTML = SPEED_OPTS.map((r) =>
800
- `<div class="vyr-sheet-item${r === this.playRate ? ' active' : ''}" data-rate="${r}">${fmtRate(r)}<span class="check">✓</span></div>`
801
- ).join('');
802
- }
803
- _renderHoldList() {
804
- if (!this.holdList) return;
805
- this.holdList.innerHTML = HOLD_SPEED_OPTS.map((r) =>
806
- `<div class="vyr-sheet-item${r === this.holdRate ? ' active' : ''}" data-rate="${r}">${fmtRate(r)}<span class="check">✓</span></div>`
807
- ).join('');
808
- const tog = this.el.querySelector('[data-tog-hold]');
809
- if (tog) tog.classList.toggle('on', this.holdSpeedOn);
810
- }
811
-
812
- _setSeekStep(n) {
813
- n = clamp(Math.round(Number(n)), 1, 300);
814
- this.seekStep = n;
815
- this._save(LS.seek, n);
816
- this._applySettingsUI();
817
- this._renderSeekList();
818
- }
819
-
820
- _applyVolume(showToast) {
821
- try {
822
- if (this.audioEl) {
823
- // video tetap mute; volume di audio track
824
- this.video.muted = true;
825
- this.audioEl.volume = this.volMuted ? 0 : this.volLevel;
826
- this.audioEl.muted = !!this.volMuted;
827
- } else {
828
- this.video.volume = this.volMuted ? 0 : this.volLevel;
829
- this.video.muted = !!this.volMuted;
830
- }
831
- } catch (_) {}
832
- const sl = this.el.querySelector('.vyr-vol-slider');
833
- if (sl) sl.value = String(Math.round((this.volMuted ? 0 : this.volLevel) * 100));
834
- const on = this.el.querySelector('.vyr-ic-vol-on');
835
- const off = this.el.querySelector('.vyr-ic-vol-off');
836
- const muted = this.volMuted || this.volLevel <= 0.001;
837
- if (on) on.style.display = muted ? 'none' : 'block';
838
- if (off) off.style.display = muted ? 'block' : 'none';
839
- this._save(LS.vol, this.volLevel);
840
- this._save(LS.muted, this.volMuted ? '1' : '0');
841
- if (showToast && this.volToast) {
842
- const tx = this.volToast.querySelector('.vyr-vol-toast-text');
843
- if (tx) tx.textContent = (this.volMuted ? 0 : Math.round(this.volLevel * 100)) + '%';
844
- this.volToast.classList.add('show');
845
- clearTimeout(this._volT);
846
- this._volT = setTimeout(() => this.volToast.classList.remove('show'), 900);
847
- }
848
- }
849
-
850
- // ─── Bind events ──────────────────────────────────────────
851
-
852
- _bindUI() {
853
- const r = this.el;
854
-
855
- // Play / pause
856
- this.btnPP?.addEventListener('click', (e) => {
857
- e.stopPropagation();
858
- this.toggle();
859
- this._showUI();
860
- });
861
-
862
- // Seek buttons
863
- this.btnRew?.addEventListener('click', (e) => {
864
- e.stopPropagation();
865
- if (this.uiLocked) return;
866
- this._safeSeek((this.video.currentTime || 0) - this.seekStep);
867
- this._toastSeek('left');
868
- this._showUI();
869
- });
870
- this.btnFf?.addEventListener('click', (e) => {
871
- e.stopPropagation();
872
- if (this.uiLocked) return;
873
- this._safeSeek((this.video.currentTime || 0) + this.seekStep);
874
- this._toastSeek('right');
875
- this._showUI();
876
- });
877
-
878
- // Double-tap zones
879
- this._bindDoubleTap($(r, '.vyr-tap-left'), -1);
880
- this._bindDoubleTap($(r, '.vyr-tap-right'), 1);
881
-
882
- // Progress scrub
883
- this._bindProgress();
884
-
885
- // Fullscreen
886
- this.btnFs?.addEventListener('click', (e) => {
887
- e.stopPropagation();
888
- this._toggleFs();
889
- this._showUI();
890
- });
891
- document.addEventListener('fullscreenchange', () => this._syncFs());
892
- document.addEventListener('webkitfullscreenchange', () => this._syncFs());
893
-
894
- // Lock
895
- this.btnLock?.addEventListener('click', (e) => {
896
- e.stopPropagation();
897
- this.uiLocked = !this.uiLocked;
898
- this._closeSheets();
899
- this._updateLockIcon();
900
- if (!this.uiLocked) this._showUI();
901
- });
902
-
903
- // Settings sheets
904
- this.btnSettings?.addEventListener('click', (e) => {
905
- e.stopPropagation();
906
- if (this.uiLocked) return;
907
- this._openSheet(this.sheetSettings);
908
- });
909
- this.btnQuality?.addEventListener('click', (e) => {
910
- e.stopPropagation();
911
- if (this.uiLocked) return;
912
- this._renderQualities();
913
- this._openSheet(this.sheetQuality);
914
- });
915
-
916
- // Subtitles CC
917
- this.btnCc?.addEventListener('click', (e) => {
918
- e.stopPropagation();
919
- if (this.uiLocked) return;
920
- this._renderSubsList();
921
- this._applySubStyle();
922
- this._openSheet(this.sheetSubs);
923
- });
924
- this.subsList?.addEventListener('click', (e) => {
925
- const item = e.target.closest('[data-sub-idx]');
926
- if (!item) return;
927
- const idx = parseInt(item.dataset.subIdx, 10);
928
- if (idx < 0) this._disableAllTracks();
929
- else this._enableTrack(idx);
930
- this._showUI();
931
- });
932
- this.el.querySelector('[data-sub-toggle]')?.addEventListener('click', (e) => {
933
- e.stopPropagation();
934
- this.subOn = !this.subOn;
935
- this._save(LS.subOn, this.subOn ? '1' : '0');
936
- this._applySubStyle();
937
- });
938
- this.el.querySelector('.vyr-sub-y')?.addEventListener('input', (e) => {
939
- this.subY = parseInt(e.target.value, 10) || 0;
940
- this._save(LS.subY, this.subY);
941
- this._applySubStyle();
942
- });
943
- this.el.querySelector('.vyr-sub-color')?.addEventListener('input', (e) => {
944
- this.subColor = e.target.value;
945
- this._save(LS.subColor, this.subColor);
946
- this._applySubStyle();
947
- });
948
- this.el.querySelector('.vyr-sub-size')?.addEventListener('input', (e) => {
949
- this.subSize = parseInt(e.target.value, 10) || 20;
950
- this._save(LS.subSize, this.subSize);
951
- this._applySubStyle();
952
- });
953
- this.el.querySelector('.vyr-sub-bg')?.addEventListener('input', (e) => {
954
- this.subBg = parseInt(e.target.value, 10) || 0;
955
- this._save(LS.subBg, this.subBg);
956
- this._applySubStyle();
957
- });
958
- const closeOverlay = (e) => {
959
- if (e) e.stopPropagation();
960
- this._closeSheets();
961
- this._showUI();
962
- };
963
- this.backdrop?.addEventListener('click', closeOverlay);
964
- this.backdrop?.addEventListener('touchend', (e) => {
965
- // touch pada area gelap = tutup
966
- if (e.target === this.backdrop) closeOverlay(e);
967
- }, { passive: false });
968
- $$(r, '[data-close]').forEach((btn) => {
969
- btn.addEventListener('click', closeOverlay);
970
- btn.addEventListener('touchend', (e) => {
971
- e.preventDefault();
972
- closeOverlay(e);
973
- }, { passive: false });
974
- });
975
-
976
- // Quality select
977
- this.qualityList?.addEventListener('click', (e) => {
978
- const item = e.target.closest('[data-label]');
979
- if (!item) return;
980
- this.engine.switchQuality(item.dataset.label);
981
- this._closeSheets();
982
- this._showUI();
983
- });
984
-
985
- // Settings rows
986
- r.querySelector('[data-set="seek"]')?.addEventListener('click', () => {
987
- this._renderSeekList();
988
- this._openSheet(this.sheetSeek);
989
- });
990
- r.querySelector('[data-set="speed"]')?.addEventListener('click', () => {
991
- this._renderSpeedList();
992
- this._openSheet(this.sheetSpeed);
993
- });
994
- r.querySelector('[data-set="hold"]')?.addEventListener('click', () => {
995
- this._renderHoldList();
996
- this._openSheet(this.sheetHold);
997
- });
998
- r.querySelector('[data-set="substyle"]')?.addEventListener('click', () => {
999
- this._applySubStyle();
1000
- this._openSheet(this.sheetSubstyle);
1001
- });
1002
- this.seekList?.addEventListener('click', (e) => {
1003
- const item = e.target.closest('[data-step]');
1004
- if (!item) return;
1005
- this._setSeekStep(parseInt(item.dataset.step, 10));
1006
- this._closeSheets();
1007
- this._showUI();
1008
- });
1009
- this.speedList?.addEventListener('click', (e) => {
1010
- const item = e.target.closest('[data-rate]');
1011
- if (!item) return;
1012
- this.setPlaybackRate(parseFloat(item.dataset.rate));
1013
- this._closeSheets();
1014
- this._showUI();
1015
- });
1016
- this.holdList?.addEventListener('click', (e) => {
1017
- const item = e.target.closest('[data-rate]');
1018
- if (!item) return;
1019
- this.holdRate = parseFloat(item.dataset.rate);
1020
- this._save(LS.holdRate, this.holdRate);
1021
- const el = r.querySelector('.vyr-set-hold-val');
1022
- if (el) el.textContent = fmtRate(this.holdRate);
1023
- this._renderHoldList();
1024
- });
1025
- r.querySelector('[data-hold-toggle]')?.addEventListener('click', () => {
1026
- this.holdSpeedOn = !this.holdSpeedOn;
1027
- this._save(LS.holdOn, this.holdSpeedOn ? '1' : '0');
1028
- this._renderHoldList();
1029
- });
1030
-
1031
- // Volume
1032
- r.querySelector('.vyr-btn-vol')?.addEventListener('click', (e) => {
1033
- e.stopPropagation();
1034
- this.volMuted = !this.volMuted;
1035
- this._applyVolume(true);
1036
- this._showUI();
1037
- });
1038
- r.querySelector('.vyr-vol-slider')?.addEventListener('input', (e) => {
1039
- e.stopPropagation();
1040
- this.volLevel = parseInt(e.target.value, 10) / 100;
1041
- if (this.volLevel > 0.001) this.volMuted = false;
1042
- this._applyVolume(true);
1043
- });
1044
-
1045
- // PiP
1046
- this.btnPip?.addEventListener('click', async (e) => {
1047
- e.stopPropagation();
1048
- if (this.uiLocked) return;
1049
- try {
1050
- if (document.pictureInPictureElement) await document.exitPictureInPicture();
1051
- else {
1052
- if (this.video.paused) await this.video.play();
1053
- await this.video.requestPictureInPicture();
1054
- }
1055
- } catch (_) {}
1056
- this._showUI();
1057
- });
1058
- r.querySelector('.vyr-btn-pip-back')?.addEventListener('click', async () => {
1059
- try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); } catch (_) {}
1060
- });
1061
- this.video?.addEventListener('enterpictureinpicture', () => this.pipOverlay?.classList.add('show'));
1062
- this.video?.addEventListener('leavepictureinpicture', () => this.pipOverlay?.classList.remove('show'));
1063
-
1064
- // Hold to speed
1065
- if (this.opts.features.holdToSpeed !== false) {
1066
- r.addEventListener('touchstart', (e) => this._onHoldStart(e), { passive: true });
1067
- r.addEventListener('touchend', () => this._onHoldEnd());
1068
- r.addEventListener('touchcancel', () => this._onHoldEnd());
1069
- r.addEventListener('mousedown', (e) => this._onHoldStart(e));
1070
- r.addEventListener('mouseup', () => this._onHoldEnd());
1071
- r.addEventListener('mouseleave', () => this._onHoldEnd());
1072
- }
1073
-
1074
- // Toggle UI on tap
1075
- r.addEventListener('click', (e) => {
1076
- if (e.target.closest('.vyr-btn-lock')) return;
1077
- if (this.uiLocked) return;
1078
- if (e.target.closest('button') || e.target.closest('.vyr-sheet') ||
1079
- e.target.closest('.vyr-progress') || e.target.closest('.vyr-chip') ||
1080
- e.target.closest('.vyr-vol-wrap')) return;
1081
- if (this._anySheetOpen()) { this._closeSheets(); return; }
1082
- this._toggleUI();
1083
- });
1084
-
1085
- // Video events
1086
- this.video.addEventListener('play', () => {
1087
- this.poster?.classList.add('hide');
1088
- this._updatePlayIcon();
1089
- this._showUI();
1090
- this._audioPlay();
1091
- this.emitter.emit('play', this.getState());
1092
- });
1093
- this.video.addEventListener('pause', () => {
1094
- this._updatePlayIcon();
1095
- this._showUI();
1096
- this._audioPause();
1097
- this.emitter.emit('pause', this.getState());
1098
- });
1099
- this.video.addEventListener('timeupdate', () => {
1100
- this._updateProgress();
1101
- this._syncManualSubs();
1102
- if (this._syncAudioTime) this._syncAudioTime();
1103
- // hilangkan bat spinner palsu saat playback jalan
1104
- if (!this.video.paused && this.video.readyState >= 2) {
1105
- if (this.buffer && this.buffer.classList.contains('show')) {
1106
- clearTimeout(this._bufT);
1107
- this._hideBuffer();
1108
- }
1109
- }
1110
- this.emitter.emit('timeupdate', this.getState());
1111
- });
1112
- this.video.addEventListener('ended', () => {
1113
- this.emitter.emit('ended', this.getState());
1114
- });
1115
- this.video.addEventListener('waiting', () => {
1116
- clearTimeout(this._bufT);
1117
- // HLS sering fire waiting tiap ganti segment — jangan langsung tampilkan bat
1118
- this._bufT = setTimeout(() => {
1119
- if (this._destroyed || !this.video) return;
1120
- if (this.video.paused) return;
1121
- // masih punya data ke depan → bukan buffering sungguhan
1122
- if (this.video.readyState >= 3) return;
1123
- try {
1124
- const t = this.video.currentTime || 0;
1125
- const buf = this.video.buffered;
1126
- for (let i = 0; i < buf.length; i++) {
1127
- if (t >= buf.start(i) - 0.15 && t < buf.end(i) - 0.35) return;
1128
- }
1129
- } catch (_) {}
1130
- this._showBuffer();
1131
- }, 500);
1132
- });
1133
- this.video.addEventListener('playing', () => {
1134
- clearTimeout(this._bufT);
1135
- this._hideBuffer();
1136
- });
1137
- this.video.addEventListener('canplay', () => {
1138
- clearTimeout(this._bufT);
1139
- this._hideBuffer();
1140
- });
1141
- this.video.addEventListener('canplaythrough', () => {
1142
- clearTimeout(this._bufT);
1143
- this._hideBuffer();
1144
- });
1145
- this.video.addEventListener('seeked', () => {
1146
- clearTimeout(this._bufT);
1147
- if (!this.video.paused) this._hideBuffer();
1148
- });
1149
-
1150
- // Keyboard
1151
- document.addEventListener('keydown', (e) => {
1152
- if (this._destroyed) return;
1153
- if (e.key === 'Escape' || e.code === 'Escape') {
1154
- if (this._anySheetOpen()) {
1155
- e.preventDefault();
1156
- this._closeSheets();
1157
- this._showUI();
1158
- }
1159
- return;
1160
- }
1161
- if (e.code === 'Space') {
1162
- e.preventDefault();
1163
- this.toggle();
1164
- this._showUI();
1165
- } else if (e.code === 'ArrowLeft') {
1166
- if (this.uiLocked) return;
1167
- this._safeSeek((this.video.currentTime || 0) - this.seekStep);
1168
- this._toastSeek('left');
1169
- this._showUI();
1170
- } else if (e.code === 'ArrowRight') {
1171
- if (this.uiLocked) return;
1172
- this._safeSeek((this.video.currentTime || 0) + this.seekStep);
1173
- this._toastSeek('right');
1174
- this._showUI();
1175
- }
1176
- });
1177
-
1178
- // Block context menu on video
1179
- r.addEventListener('contextmenu', (e) => {
1180
- if (e.target.closest('video') || e.target.closest('.vyr-root')) e.preventDefault();
1181
- });
1182
-
1183
- this._showUI();
1184
- }
1185
-
1186
- _bindDoubleTap(zone, dir) {
1187
- if (!zone) return;
1188
- let last = 0;
1189
- zone.addEventListener('touchend', (e) => {
1190
- const now = Date.now();
1191
- if (now - last < 300) {
1192
- if (this.uiLocked) return;
1193
- this._safeSeek((this.video.currentTime || 0) + dir * this.seekStep);
1194
- this._toastSeek(dir < 0 ? 'left' : 'right');
1195
- e.preventDefault();
1196
- this._showUI();
1197
- }
1198
- last = now;
1199
- }, { passive: false });
1200
- }
1201
-
1202
- _bindProgress() {
1203
- if (!this.progress) return;
1204
- let scrubTarget = 0;
1205
- const pctFrom = (e) => {
1206
- const rect = this.progress.getBoundingClientRect();
1207
- const clientX = e.touches?.[0]?.clientX ?? e.changedTouches?.[0]?.clientX ?? e.clientX;
1208
- return clamp((clientX - rect.left) / rect.width, 0, 1);
1209
- };
1210
- const preview = (e) => {
1211
- const pct = pctFrom(e);
1212
- const d = isFinite(this.video.duration) ? this.video.duration : 0;
1213
- scrubTarget = d * pct;
1214
- this._paintProgress(scrubTarget, d, true);
1215
- this.uiVisible = true;
1216
- this.ui?.classList.remove('hidden');
1217
- clearTimeout(this.hideTimer);
1218
- };
1219
- const end = (e) => {
1220
- if (!this.seeking) return;
1221
- this.seeking = false;
1222
- this.progress.classList.remove('scrubbing');
1223
- const pct = e ? pctFrom(e) : null;
1224
- const d = isFinite(this.video.duration) ? this.video.duration : 0;
1225
- const t = (pct != null && d) ? d * pct : scrubTarget;
1226
- this.scrubTip?.classList.remove('show');
1227
- if (d > 0) this._safeSeek(t);
1228
- this._showUI();
1229
- };
1230
- this.progress.addEventListener('click', (e) => {
1231
- e.stopPropagation();
1232
- if (this.seeking) return;
1233
- const pct = pctFrom(e);
1234
- const d = isFinite(this.video.duration) ? this.video.duration : 0;
1235
- if (d > 0) this._safeSeek(d * pct);
1236
- this._showUI();
1237
- });
1238
- this.progress.addEventListener('touchstart', (e) => {
1239
- this.seeking = true;
1240
- this.progress.classList.add('scrubbing');
1241
- e.stopPropagation();
1242
- preview(e);
1243
- }, { passive: true });
1244
- this.progress.addEventListener('touchmove', (e) => { if (this.seeking) preview(e); }, { passive: true });
1245
- this.progress.addEventListener('touchend', end);
1246
- this.progress.addEventListener('touchcancel', end);
1247
- this.progress.addEventListener('mousedown', (e) => {
1248
- if (e.button !== 0) return;
1249
- this.seeking = true;
1250
- this.progress.classList.add('scrubbing');
1251
- e.preventDefault();
1252
- preview(e);
1253
- const onMove = (ev) => { if (this.seeking) preview(ev); };
1254
- const onUp = (ev) => {
1255
- document.removeEventListener('mousemove', onMove);
1256
- document.removeEventListener('mouseup', onUp);
1257
- end(ev);
1258
- };
1259
- document.addEventListener('mousemove', onMove);
1260
- document.addEventListener('mouseup', onUp);
1261
- });
1262
- }
1263
-
1264
- _toastSeek(side) {
1265
- const el = this.el.querySelector(side === 'left' ? '.vyr-seek-left' : '.vyr-seek-right');
1266
- if (!el) return;
1267
- const sec = el.querySelector('.sec');
1268
- if (sec) sec.textContent = side === 'left' ? ('«' + this.seekStep) : (this.seekStep + '»');
1269
- el.classList.remove('show');
1270
- void el.offsetWidth;
1271
- el.classList.add('show');
1272
- clearTimeout(el._t);
1273
- el._t = setTimeout(() => el.classList.remove('show'), 700);
1274
- }
1275
-
1276
- _onHoldStart(e) {
1277
- if (!this.holdSpeedOn || this.uiLocked || this.video.paused) return;
1278
- if (e.target.closest('button') || e.target.closest('.vyr-sheet') ||
1279
- e.target.closest('.vyr-progress') || e.target.closest('.vyr-chip')) return;
1280
- clearTimeout(this.holdTimer);
1281
- this.holdTimer = setTimeout(() => {
1282
- this.isHoldingSpeed = true;
1283
- try { this.video.playbackRate = this.holdRate; } catch (_) {}
1284
- if (this.speedBadge) {
1285
- this.speedBadge.textContent = fmtRate(this.holdRate);
1286
- this.speedBadge.classList.add('show');
1287
- }
1288
- }, 320);
1289
- }
1290
- _onHoldEnd() {
1291
- clearTimeout(this.holdTimer);
1292
- if (!this.isHoldingSpeed) return;
1293
- this.isHoldingSpeed = false;
1294
- try { this.video.playbackRate = this.playRate; } catch (_) {}
1295
- this.speedBadge?.classList.remove('show');
1296
- }
1297
-
1298
- _updateLockIcon() {
1299
- const open = this.el.querySelector('.vyr-ic-lock-open');
1300
- const closed = this.el.querySelector('.vyr-ic-lock-closed');
1301
- if (this.uiLocked) {
1302
- if (open) open.style.display = 'none';
1303
- if (closed) closed.style.display = 'block';
1304
- this.btnLock?.classList.add('active-lock');
1305
- this.ui?.classList.add('ui-locked');
1306
- this.ui?.classList.remove('hidden');
1307
- } else {
1308
- if (open) open.style.display = 'block';
1309
- if (closed) closed.style.display = 'none';
1310
- this.btnLock?.classList.remove('active-lock');
1311
- this.ui?.classList.remove('ui-locked');
1312
- }
1313
- }
1314
-
1315
- _toggleFs() {
1316
- const el = this.el;
1317
- if (!document.fullscreenElement && !document.webkitFullscreenElement) {
1318
- (el.requestFullscreen || el.webkitRequestFullscreen || (() => {})).call(el);
1319
- try { screen.orientation?.lock?.('landscape'); } catch (_) {}
1320
- } else {
1321
- (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document);
1322
- try { screen.orientation?.unlock?.(); } catch (_) {}
1323
- }
1324
- }
1325
- _syncFs() {
1326
- const on = !!(document.fullscreenElement || document.webkitFullscreenElement);
1327
- this.el.classList.toggle('is-fs', on);
1328
- }
1329
- }
1330
-
1331
- export default VyrPlay;