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/LICENSE +25 -21
- package/README.md +21 -24
- package/dist/vyrplay.esm.js +30 -1754
- package/dist/vyrplay.umd.js +30 -1794
- package/package.json +14 -14
- package/src/VyrPlay.js +0 -1331
- package/src/engine/MediaEngine.js +0 -369
- package/src/index.js +0 -3
- package/src/ui/styles.css +0 -681
- package/src/ui/template.js +0 -220
- package/src/utils/events.js +0 -25
- package/src/utils/format.js +0 -20
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MediaEngine — handles MP4 / HLS / multi-quality with smart fallback.
|
|
3
|
-
* hls.js is loaded lazily only when needed.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
let HlsLib = null;
|
|
7
|
-
|
|
8
|
-
async function ensureHls() {
|
|
9
|
-
if (HlsLib) return HlsLib;
|
|
10
|
-
if (typeof window !== 'undefined' && window.Hls) {
|
|
11
|
-
HlsLib = window.Hls;
|
|
12
|
-
return HlsLib;
|
|
13
|
-
}
|
|
14
|
-
try {
|
|
15
|
-
const mod = await import('hls.js');
|
|
16
|
-
HlsLib = mod.default || mod;
|
|
17
|
-
return HlsLib;
|
|
18
|
-
} catch (_) {
|
|
19
|
-
// CDN fallback
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
if (typeof document === 'undefined') return reject(new Error('No Hls'));
|
|
22
|
-
const s = document.createElement('script');
|
|
23
|
-
s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.7';
|
|
24
|
-
s.onload = () => {
|
|
25
|
-
HlsLib = window.Hls;
|
|
26
|
-
resolve(HlsLib);
|
|
27
|
-
};
|
|
28
|
-
s.onerror = () => reject(new Error('Failed to load hls.js'));
|
|
29
|
-
document.head.appendChild(s);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class MediaEngine {
|
|
35
|
-
constructor(video, opts = {}) {
|
|
36
|
-
this.video = video;
|
|
37
|
-
this.hls = null;
|
|
38
|
-
this.sources = [];
|
|
39
|
-
this.currentIndex = 0;
|
|
40
|
-
this.currentQuality = null;
|
|
41
|
-
this.isHls = false;
|
|
42
|
-
this._destroyed = false;
|
|
43
|
-
this.onError = opts.onError || (() => {});
|
|
44
|
-
this.onReady = opts.onReady || (() => {});
|
|
45
|
-
this.onQualityChange = opts.onQualityChange || (() => {});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Normalize options into internal sources list.
|
|
50
|
-
* Accepts:
|
|
51
|
-
* sources: [{ type, url, label, height }]
|
|
52
|
-
* src + type
|
|
53
|
-
* single string url
|
|
54
|
-
*/
|
|
55
|
-
setSources(input) {
|
|
56
|
-
let list = [];
|
|
57
|
-
if (Array.isArray(input) && input.length) {
|
|
58
|
-
list = input.map((s) => this._norm(s));
|
|
59
|
-
} else if (input && typeof input === 'object' && input.url) {
|
|
60
|
-
list = [this._norm(input)];
|
|
61
|
-
} else if (typeof input === 'string') {
|
|
62
|
-
list = [this._norm({ url: input })];
|
|
63
|
-
}
|
|
64
|
-
// sort by height desc
|
|
65
|
-
list.sort((a, b) => (b.height || 0) - (a.height || 0));
|
|
66
|
-
// dedupe labels
|
|
67
|
-
const seen = new Set();
|
|
68
|
-
this.sources = list.filter((s) => {
|
|
69
|
-
if (!s.url || seen.has(s.label)) return false;
|
|
70
|
-
seen.add(s.label);
|
|
71
|
-
return true;
|
|
72
|
-
});
|
|
73
|
-
this.currentIndex = 0;
|
|
74
|
-
this.currentQuality = this.sources[0]?.label || null;
|
|
75
|
-
return this.sources;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
_norm(s) {
|
|
79
|
-
if (typeof s === 'string') s = { url: s };
|
|
80
|
-
const url = String(s.url || '');
|
|
81
|
-
let type = (s.type || '').toLowerCase();
|
|
82
|
-
if (!type) {
|
|
83
|
-
if (/\.m3u8(\?|$)/i.test(url) || /application\/vnd\.apple\.mpegurl/i.test(s.mime || '')) {
|
|
84
|
-
type = 'hls';
|
|
85
|
-
} else {
|
|
86
|
-
type = 'mp4';
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const height = s.height || this._guessHeight(s.label || s.quality || '') || 0;
|
|
90
|
-
const label = s.label || (height ? height + 'p' : (s.quality || 'Auto'));
|
|
91
|
-
return { type, url, label, height, name: s.name };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
_guessHeight(str) {
|
|
95
|
-
const m = String(str).match(/(\d{3,4})\s*p/i);
|
|
96
|
-
if (m) return parseInt(m[1], 10);
|
|
97
|
-
const map = { fullhd: 1080, full: 1080, hd: 720, sd: 480, low: 360, mobile: 144, ultra: 2160, quad: 1440 };
|
|
98
|
-
return map[String(str).toLowerCase()] || 0;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
getQualities() {
|
|
102
|
-
return this.sources.map((s) => ({ label: s.label, height: s.height, url: s.url, type: s.type }));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async load(startTime = 0, autoplay = true) {
|
|
106
|
-
if (this._destroyed || !this.sources.length) {
|
|
107
|
-
this.onError({ message: 'Tidak ada sumber video' });
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
return this._tryIndex(0, startTime, autoplay);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async _tryIndex(idx, startTime, autoplay) {
|
|
114
|
-
if (this._destroyed) return false;
|
|
115
|
-
if (idx >= this.sources.length) {
|
|
116
|
-
this.onError({ message: 'Semua sumber gagal dimuat' });
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
this.currentIndex = idx;
|
|
120
|
-
const src = this.sources[idx];
|
|
121
|
-
this.currentQuality = src.label;
|
|
122
|
-
this.onQualityChange(src.label);
|
|
123
|
-
|
|
124
|
-
this._destroyHls();
|
|
125
|
-
try {
|
|
126
|
-
this.video.pause();
|
|
127
|
-
this.video.removeAttribute('src');
|
|
128
|
-
this.video.load();
|
|
129
|
-
} catch (_) {}
|
|
130
|
-
|
|
131
|
-
if (src.type === 'hls') {
|
|
132
|
-
return this._loadHls(src.url, startTime, autoplay, idx);
|
|
133
|
-
}
|
|
134
|
-
return this._loadDirect(src.url, startTime, autoplay, idx);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
_cleanUrl(url) {
|
|
138
|
-
try {
|
|
139
|
-
const u = new URL(url, typeof location !== 'undefined' ? location.href : undefined);
|
|
140
|
-
u.hash = '';
|
|
141
|
-
return u.toString();
|
|
142
|
-
} catch (_) {
|
|
143
|
-
return String(url || '').split('#')[0];
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
_loadDirect(url, startTime, autoplay, idx) {
|
|
148
|
-
return new Promise((resolve) => {
|
|
149
|
-
let settled = false;
|
|
150
|
-
const finish = (ok) => {
|
|
151
|
-
if (settled) return;
|
|
152
|
-
settled = true;
|
|
153
|
-
this.video.removeEventListener('loadedmetadata', onOk);
|
|
154
|
-
this.video.removeEventListener('canplay', onOk);
|
|
155
|
-
this.video.removeEventListener('error', onErr);
|
|
156
|
-
if (!ok) {
|
|
157
|
-
this._tryIndex(idx + 1, startTime, autoplay).then(resolve);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
if (startTime > 0.5 && isFinite(startTime)) {
|
|
162
|
-
this.video.currentTime = startTime;
|
|
163
|
-
}
|
|
164
|
-
} catch (_) {}
|
|
165
|
-
if (autoplay) this.video.play().catch(() => {});
|
|
166
|
-
this.onReady({ quality: this.currentQuality, type: 'mp4' });
|
|
167
|
-
resolve(true);
|
|
168
|
-
};
|
|
169
|
-
const onOk = () => finish(true);
|
|
170
|
-
const onErr = () => finish(false);
|
|
171
|
-
this.video.src = this._cleanUrl(url);
|
|
172
|
-
try { this.video.load(); } catch (_) {}
|
|
173
|
-
this.video.addEventListener('loadedmetadata', onOk, { once: true });
|
|
174
|
-
this.video.addEventListener('canplay', onOk, { once: true });
|
|
175
|
-
this.video.addEventListener('error', onErr, { once: true });
|
|
176
|
-
setTimeout(() => {
|
|
177
|
-
if (!settled) {
|
|
178
|
-
const ok = this.video.readyState >= 2 || (this.video.duration > 0 && isFinite(this.video.duration));
|
|
179
|
-
finish(!!ok);
|
|
180
|
-
}
|
|
181
|
-
}, 18000);
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async _loadHls(url, startTime, autoplay, idx) {
|
|
186
|
-
const clean = this._cleanUrl(url);
|
|
187
|
-
try {
|
|
188
|
-
// Native HLS (Safari / some Android) — same path as "buka URL di browser"
|
|
189
|
-
// Jangan set crossOrigin: segment CDN sering tanpa ACAO → stuck loading.
|
|
190
|
-
if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
191
|
-
return this._loadDirect(clean, startTime, autoplay, idx);
|
|
192
|
-
}
|
|
193
|
-
const Hls = await ensureHls();
|
|
194
|
-
if (!Hls || !Hls.isSupported()) {
|
|
195
|
-
return this._tryIndex(idx + 1, startTime, autoplay);
|
|
196
|
-
}
|
|
197
|
-
this.isHls = true;
|
|
198
|
-
this.hls = new Hls({
|
|
199
|
-
enableWorker: true,
|
|
200
|
-
lowLatencyMode: false,
|
|
201
|
-
backBufferLength: 30,
|
|
202
|
-
maxBufferLength: 45,
|
|
203
|
-
maxMaxBufferLength: 90,
|
|
204
|
-
maxBufferHole: 1.0,
|
|
205
|
-
nudgeMaxRetry: 5,
|
|
206
|
-
fragLoadingTimeOut: 25000,
|
|
207
|
-
manifestLoadingTimeOut: 20000,
|
|
208
|
-
levelLoadingTimeOut: 20000,
|
|
209
|
-
startPosition: startTime > 0 ? startTime : -1,
|
|
210
|
-
xhrSetup: function (xhr) {
|
|
211
|
-
// jangan kirim credentials ke CDN
|
|
212
|
-
try { xhr.withCredentials = false; } catch (_) {}
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
this.hls.loadSource(clean);
|
|
216
|
-
this.hls.attachMedia(this.video);
|
|
217
|
-
|
|
218
|
-
return new Promise((resolve) => {
|
|
219
|
-
let settled = false;
|
|
220
|
-
const finish = (ok, reason) => {
|
|
221
|
-
if (settled) return;
|
|
222
|
-
settled = true;
|
|
223
|
-
if (!ok) {
|
|
224
|
-
this._destroyHls();
|
|
225
|
-
this._tryIndex(idx + 1, startTime, autoplay).then(resolve);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (startTime > 0.5) {
|
|
229
|
-
try { this.video.currentTime = startTime; } catch (_) {}
|
|
230
|
-
}
|
|
231
|
-
if (autoplay) {
|
|
232
|
-
const p = this.video.play();
|
|
233
|
-
if (p && p.catch) p.catch(() => {});
|
|
234
|
-
}
|
|
235
|
-
this.onReady({ quality: this.currentQuality, type: 'hls', reason });
|
|
236
|
-
resolve(true);
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
|
240
|
-
// Master playlist → expose levels as qualities
|
|
241
|
-
try {
|
|
242
|
-
const levels = this.hls.levels || [];
|
|
243
|
-
if (levels.length > 1) {
|
|
244
|
-
const mapped = levels.map((lv, i) => ({
|
|
245
|
-
label: (lv.height ? lv.height + 'p' : ('L' + i)),
|
|
246
|
-
height: lv.height || 0,
|
|
247
|
-
url: clean,
|
|
248
|
-
type: 'hls',
|
|
249
|
-
level: i,
|
|
250
|
-
})).sort((a, b) => (b.height || 0) - (a.height || 0));
|
|
251
|
-
// merge into sources for UI (keep current as hls levels)
|
|
252
|
-
this._hlsLevels = mapped;
|
|
253
|
-
this.sources = mapped.length ? mapped : this.sources;
|
|
254
|
-
this.currentQuality = mapped[0]?.label || this.currentQuality;
|
|
255
|
-
this.onQualityChange(this.currentQuality);
|
|
256
|
-
} else if (levels.length === 1) {
|
|
257
|
-
const lv = levels[0];
|
|
258
|
-
const label = lv.height ? (lv.height + 'p') : (this.currentQuality || 'Auto');
|
|
259
|
-
this.currentQuality = label;
|
|
260
|
-
this.onQualityChange(label);
|
|
261
|
-
}
|
|
262
|
-
} catch (_) {}
|
|
263
|
-
// don't finish yet — wait until we can play or first frag
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
this.hls.on(Hls.Events.FRAG_BUFFERED, () => finish(true, 'frag'));
|
|
267
|
-
this.video.addEventListener('canplay', () => finish(true, 'canplay'), { once: true });
|
|
268
|
-
this.video.addEventListener('loadeddata', () => {
|
|
269
|
-
if (this.video.readyState >= 2) finish(true, 'loadeddata');
|
|
270
|
-
}, { once: true });
|
|
271
|
-
|
|
272
|
-
this.hls.on(Hls.Events.ERROR, (_, data) => {
|
|
273
|
-
if (!data) return;
|
|
274
|
-
if (data.fatal) {
|
|
275
|
-
const type = data.type;
|
|
276
|
-
try {
|
|
277
|
-
if (type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
278
|
-
this.hls.startLoad();
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
282
|
-
this.hls.recoverMediaError();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
} catch (_) {}
|
|
286
|
-
finish(false, 'fatal');
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
setTimeout(() => {
|
|
291
|
-
if (!settled) {
|
|
292
|
-
// partial success if we have duration
|
|
293
|
-
const ok = this.video.readyState >= 2 || (this.video.duration > 0 && isFinite(this.video.duration));
|
|
294
|
-
finish(!!ok, 'timeout');
|
|
295
|
-
}
|
|
296
|
-
}, 25000);
|
|
297
|
-
});
|
|
298
|
-
} catch (err) {
|
|
299
|
-
return this._tryIndex(idx + 1, startTime, autoplay);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Switch quality by label — keep current time */
|
|
304
|
-
async switchQuality(label) {
|
|
305
|
-
if (!label || label === this.currentQuality) return;
|
|
306
|
-
const q = this.sources.find((s) => s.label === label);
|
|
307
|
-
if (!q) return;
|
|
308
|
-
|
|
309
|
-
const time = this.video.currentTime || 0;
|
|
310
|
-
const wasPlaying = !this.video.paused;
|
|
311
|
-
const prev = this.currentQuality;
|
|
312
|
-
this.currentQuality = label;
|
|
313
|
-
this.onQualityChange(label);
|
|
314
|
-
|
|
315
|
-
// HLS level switch if possible
|
|
316
|
-
if (this.isHls && this.hls && this.hls.levels && this.hls.levels.length) {
|
|
317
|
-
const wantH = parseInt(String(label).replace(/[^\d]/g, ''), 10) || 0;
|
|
318
|
-
let lvl = -1;
|
|
319
|
-
for (let i = 0; i < this.hls.levels.length; i++) {
|
|
320
|
-
if ((this.hls.levels[i].height || 0) === wantH) { lvl = i; break; }
|
|
321
|
-
}
|
|
322
|
-
if (lvl < 0 && wantH) {
|
|
323
|
-
let best = -1, bestDiff = 1e9;
|
|
324
|
-
for (let i = 0; i < this.hls.levels.length; i++) {
|
|
325
|
-
const d = Math.abs((this.hls.levels[i].height || 0) - wantH);
|
|
326
|
-
if (d < bestDiff) { bestDiff = d; best = i; }
|
|
327
|
-
}
|
|
328
|
-
lvl = best;
|
|
329
|
-
}
|
|
330
|
-
if (lvl >= 0) {
|
|
331
|
-
this.hls.currentLevel = lvl;
|
|
332
|
-
this.hls.loadLevel = lvl;
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
// label may map to stored level index
|
|
336
|
-
const fromSrc = this.sources.find((s) => s.label === label && s.level != null);
|
|
337
|
-
if (fromSrc && fromSrc.level >= 0) {
|
|
338
|
-
this.hls.currentLevel = fromSrc.level;
|
|
339
|
-
this.hls.loadLevel = fromSrc.level;
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Progressive: change src, restore time
|
|
345
|
-
if (q.type === 'hls') {
|
|
346
|
-
await this._loadHls(q.url, time, wasPlaying, this.currentIndex);
|
|
347
|
-
} else {
|
|
348
|
-
await this._loadDirect(q.url, time, wasPlaying, this.sources.indexOf(q));
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
_destroyHls() {
|
|
353
|
-
if (this.hls) {
|
|
354
|
-
try { this.hls.destroy(); } catch (_) {}
|
|
355
|
-
this.hls = null;
|
|
356
|
-
}
|
|
357
|
-
this.isHls = false;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
destroy() {
|
|
361
|
-
this._destroyed = true;
|
|
362
|
-
this._destroyHls();
|
|
363
|
-
try {
|
|
364
|
-
this.video.pause();
|
|
365
|
-
this.video.removeAttribute('src');
|
|
366
|
-
this.video.load();
|
|
367
|
-
} catch (_) {}
|
|
368
|
-
}
|
|
369
|
-
}
|
package/src/index.js
DELETED