eb-player 2.0.12 → 2.0.14

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.
@@ -1,2393 +0,0 @@
1
- (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.EBPlayerEngines = {}));
5
- })(this, (function (exports) { 'use strict';
6
-
7
- /**
8
- * EngineStateSync is the abstract base class defining the contract for Phase 2
9
- * engine bridge implementations (HLS, DASH).
10
- *
11
- * Phase 2 HLS and DASH engines extend this class to bridge DOM video events
12
- * to the PlayerState store. The attach() method receives the PlayerState and
13
- * an AbortSignal for automatic cleanup — engines register video element
14
- * event listeners using the signal to ensure zero-leak cleanup on dispose().
15
- *
16
- * Usage pattern in Phase 2:
17
- * class HlsEngineSync extends EngineStateSync {
18
- * attach(state: PlayerState, signal: AbortSignal): void {
19
- * video.addEventListener('timeupdate', () => { state.currentTime = video.currentTime }, { signal })
20
- * }
21
- * detach(): void {
22
- * this.driver?.destroy()
23
- * }
24
- * }
25
- */
26
- class EngineStateSync {
27
- }
28
-
29
- /**
30
- * StallWatchdog - Automatic playback recovery for live streams.
31
- *
32
- * Detects silent stalls (video.currentTime not advancing) and escalates
33
- * through recovery attempts before requesting a full stream reload.
34
- *
35
- * Ported from component/engines/common.js:
36
- * startStallWatchdog / stopStallWatchdog / recoverFromStall
37
- *
38
- * Escalation sequence (10s interval):
39
- * - Attempt 1 (10s stuck): calls onRecoverAttempt(1)
40
- * - Attempt 2 (20s stuck): calls onRecoverAttempt(2)
41
- * - Attempt 3 (30s stuck): calls onReloadRequired(), stops watchdog
42
- */
43
- class StallWatchdog {
44
- constructor(options) {
45
- this.intervalId = null;
46
- this.lastTime = null;
47
- this.stallAttempts = 0;
48
- this.video = options.video;
49
- this.onRecoverAttempt = options.onRecoverAttempt;
50
- this.onReloadRequired = options.onReloadRequired;
51
- this.isCasting = options.isCasting;
52
- }
53
- /**
54
- * Start the stall watchdog interval.
55
- * Calls stop() first to ensure idempotent restart.
56
- */
57
- start() {
58
- this.stop();
59
- this.intervalId = setInterval(() => {
60
- this.tick();
61
- }, 10000);
62
- }
63
- /**
64
- * Stop the stall watchdog and reset all internal state.
65
- */
66
- stop() {
67
- if (this.intervalId !== null) {
68
- clearInterval(this.intervalId);
69
- this.intervalId = null;
70
- }
71
- this.lastTime = null;
72
- this.stallAttempts = 0;
73
- }
74
- tick() {
75
- // Skip check when paused or casting
76
- if (this.video.paused || this.isCasting()) {
77
- this.lastTime = null;
78
- return;
79
- }
80
- const currentTime = this.video.currentTime;
81
- if (this.lastTime !== null && currentTime === this.lastTime) {
82
- // currentTime not advancing — stall detected
83
- this.stallAttempts += 1;
84
- if (this.stallAttempts <= 2) {
85
- this.onRecoverAttempt(this.stallAttempts);
86
- }
87
- else {
88
- this.onReloadRequired();
89
- this.stop();
90
- }
91
- }
92
- else {
93
- // currentTime advanced (or first tick) — reset stall counter
94
- this.stallAttempts = 0;
95
- this.lastTime = currentTime;
96
- }
97
- }
98
- }
99
-
100
- /**
101
- * BaseEngine — abstract base class for HLS and DASH engine implementations.
102
- *
103
- * Extends EngineStateSync with concrete lifecycle management:
104
- * - stores video, state, signal, bus, config
105
- * - binds native video element events to PlayerState
106
- * - delegates engine-specific startup / teardown to onAttach / onDetach
107
- * - delegates stall recovery to recoverFromStall (engine-specific)
108
- */
109
- class BaseEngine extends EngineStateSync {
110
- constructor() {
111
- super(...arguments);
112
- this.video = null;
113
- this.state = null;
114
- this.signal = null;
115
- this.bus = null;
116
- this.config = null;
117
- this.watchdog = null;
118
- }
119
- // -------------------------------------------------------------------------
120
- // EngineStateSync contract
121
- // -------------------------------------------------------------------------
122
- attach(state, signal) {
123
- this.state = state;
124
- this.signal = signal;
125
- void this.onAttach();
126
- }
127
- detach() {
128
- this.watchdog?.stop();
129
- this.watchdog = null;
130
- this.onDetach();
131
- this.video = null;
132
- this.state = null;
133
- this.signal = null;
134
- this.bus = null;
135
- this.config = null;
136
- }
137
- // -------------------------------------------------------------------------
138
- // Stall watchdog helpers
139
- // -------------------------------------------------------------------------
140
- startWatchdog() {
141
- if (!this.video || !this.bus)
142
- return;
143
- const busRef = this.bus;
144
- this.watchdog = new StallWatchdog({
145
- video: this.video,
146
- onRecoverAttempt: (attempt) => {
147
- this.recoverFromStall(attempt);
148
- },
149
- onReloadRequired: () => {
150
- busRef.emit('request-reload');
151
- },
152
- isCasting: () => {
153
- return this.state?.isCasting ?? false;
154
- }
155
- });
156
- this.watchdog.start();
157
- }
158
- stopWatchdog() {
159
- this.watchdog?.stop();
160
- this.watchdog = null;
161
- }
162
- // -------------------------------------------------------------------------
163
- // Video element event binding
164
- // -------------------------------------------------------------------------
165
- // -------------------------------------------------------------------------
166
- // Public setters — used by factory/test code before attach()
167
- // -------------------------------------------------------------------------
168
- setConfig(config) {
169
- this.config = config;
170
- }
171
- setBus(bus) {
172
- this.bus = bus;
173
- }
174
- setVideo(video) {
175
- this.video = video;
176
- }
177
- // -------------------------------------------------------------------------
178
- // Video element event binding
179
- // -------------------------------------------------------------------------
180
- bindVideoEvents(video, state, signal) {
181
- // Playback timing
182
- video.addEventListener('timeupdate', () => {
183
- state.currentTime = Math.round(video.currentTime);
184
- }, { signal });
185
- video.addEventListener('durationchange', () => {
186
- state.duration = Math.ceil(video.duration);
187
- }, { signal });
188
- // Volume / mute
189
- video.addEventListener('volumechange', () => {
190
- state.volume = video.volume;
191
- state.muted = video.muted;
192
- }, { signal });
193
- // Buffer progress
194
- video.addEventListener('progress', () => {
195
- if (video.buffered.length > 0) {
196
- state.bufferedEnd = video.buffered.end(video.buffered.length - 1);
197
- }
198
- }, { signal });
199
- // Playback rate
200
- video.addEventListener('ratechange', () => {
201
- state.playbackRate = video.playbackRate;
202
- }, { signal });
203
- // FSM transitions: playing, pause, waiting, ended
204
- video.addEventListener('playing', () => {
205
- state.playbackState = 'playing';
206
- }, { signal });
207
- video.addEventListener('pause', () => {
208
- state.playbackState = 'paused';
209
- }, { signal });
210
- video.addEventListener('waiting', () => {
211
- state.playbackState = 'buffering';
212
- }, { signal });
213
- video.addEventListener('ended', () => {
214
- state.playbackState = 'ended';
215
- }, { signal });
216
- }
217
- }
218
-
219
- /**
220
- * CDN Script Loader
221
- *
222
- * Replaces the scriptjs-based component/utils/load.js with direct <script> tag injection.
223
- * Resolves with window[globalName] when the script loads, or rejects with a clear error.
224
- */
225
- /**
226
- * Loads a CDN script by injecting a <script> tag into document.head and resolves
227
- * with window[globalName] once the script has loaded.
228
- *
229
- * - If window[globalName] already exists, resolves immediately (no re-injection).
230
- * - If the script loads but the global is not on window, rejects with a descriptive error.
231
- * - If script.onerror fires, rejects with a descriptive error.
232
- * - If neither onload nor onerror fires within `options.timeout` ms (default 10000), rejects.
233
- */
234
- function loadScript(url, globalName, options = {}) {
235
- const windowRecord = window;
236
- if (windowRecord[globalName] !== undefined) {
237
- return Promise.resolve(windowRecord[globalName]);
238
- }
239
- const timeoutMs = options.timeout ?? 10000;
240
- return new Promise((resolve, reject) => {
241
- let timeoutHandle;
242
- const cleanup = () => {
243
- clearTimeout(timeoutHandle);
244
- };
245
- const script = document.createElement('script');
246
- script.src = url;
247
- script.onload = () => {
248
- cleanup();
249
- const lib = windowRecord[globalName];
250
- if (lib === undefined) {
251
- reject(new Error(`CDN: '${globalName}' not on window after loading ${url}`));
252
- }
253
- else {
254
- resolve(lib);
255
- }
256
- };
257
- script.onerror = () => {
258
- cleanup();
259
- reject(new Error(`CDN: failed to load ${url}`));
260
- };
261
- timeoutHandle = setTimeout(() => {
262
- reject(new Error(`CDN: timeout loading ${url}`));
263
- }, timeoutMs);
264
- document.head.appendChild(script);
265
- });
266
- }
267
-
268
- /**
269
- * CDN Token Manager
270
- *
271
- * TypeScript port of component/utils/token.js (CDNTokenManager class, 459 LOC).
272
- * Supports four token flows: default (Cloudflare query params), easybroadcast/venom
273
- * (EasyBroadcast format via POST), akamai (path-segment format), and bunny (POST JSON).
274
- *
275
- * All sub-types (TokenError, TokenResponse, ResponseParameters) are inlined here
276
- * since they are only used by CDNTokenManager.
277
- */
278
- // ---------------------------------------------------------------------------
279
- // Token type constants
280
- // ---------------------------------------------------------------------------
281
- const TOKEN_TYPES = Object.freeze({
282
- EASY_B: 'easy_b',
283
- BUNNY: 'bunny',
284
- AKAMAI: 'akamai',
285
- VENOM: 'venom'
286
- });
287
- // ---------------------------------------------------------------------------
288
- // Error types
289
- // ---------------------------------------------------------------------------
290
- const ERROR_TYPES = Object.freeze({
291
- UNKNOWN: 'unknown',
292
- MAX_RETRY: 'max_retry',
293
- FORBIDDEN: 'forbidden'
294
- });
295
- class TokenError extends Error {
296
- constructor({ status = 'unknown', errorCode = 'unknown', errorType = ERROR_TYPES.UNKNOWN, message = null }) {
297
- super(message ?? `TokenError: ${status} - ${errorCode}`);
298
- this.status = status;
299
- this.errorCode = errorCode;
300
- this.errorType = errorType;
301
- }
302
- }
303
- class TokenResponse {
304
- constructor({ src, responseParameters, error = null, extras = {} }) {
305
- this.src = src;
306
- this.responseParameters = responseParameters;
307
- this.extras = extras ?? {};
308
- if (error !== null && !(error instanceof TokenError)) {
309
- throw new Error('TokenResponse error must be an instance of TokenError');
310
- }
311
- this.error = error;
312
- }
313
- static fromJson({ src, json }) {
314
- const obj = JSON.parse(json);
315
- const responseParameters = {
316
- token: obj.token ?? null,
317
- tokenPath: obj.token_path ?? null,
318
- expires: obj.expires ?? null
319
- };
320
- const tokenResponse = new TokenResponse({ src, responseParameters });
321
- // Copy extra properties (e.g. drm_custom_data)
322
- for (const key of Object.keys(obj)) {
323
- if (!['token', 'expires', 'token_path'].includes(key)) {
324
- tokenResponse.extras[key] = obj[key];
325
- }
326
- }
327
- return tokenResponse;
328
- }
329
- static fromAkamai({ src, text }) {
330
- const responseParameters = parseAkamaiResponse({ src, text });
331
- return new TokenResponse({ src, responseParameters });
332
- }
333
- static fromText({ src, text }) {
334
- if (text.trim().startsWith('{')) {
335
- return TokenResponse.fromJson({ src, json: text });
336
- }
337
- const params = new URLSearchParams(text.trim().replace(/^\?/, ''));
338
- const token = params.get('token') ?? null;
339
- const expiresStr = params.get('expires');
340
- const expires = expiresStr ? parseInt(expiresStr, 10) : null;
341
- const tokenPath = params.get('token_path') ?? null;
342
- const responseParameters = { token, tokenPath, expires };
343
- const tokenResponse = new TokenResponse({ src, responseParameters });
344
- params.forEach((value, key) => {
345
- if (!['token', 'expires', 'token_path'].includes(key)) {
346
- tokenResponse.extras[key] = value;
347
- }
348
- });
349
- return tokenResponse;
350
- }
351
- static fromDefault({ src, text }) {
352
- const params = new URLSearchParams(text.trim().replace(/^\?/, ''));
353
- const expiresStr = params.get('expires');
354
- const responseParameters = {
355
- verify: params.get('verify') ?? null,
356
- expires: expiresStr ? parseInt(expiresStr, 10) : null
357
- };
358
- const tokenResponse = new TokenResponse({ src, responseParameters });
359
- params.forEach((value, key) => {
360
- if (!['verify', 'expires'].includes(key)) {
361
- tokenResponse.extras[key] = value;
362
- }
363
- });
364
- return tokenResponse;
365
- }
366
- static fromError({ src, error }) {
367
- const tokenError = error instanceof TokenError ? error : new TokenError(error);
368
- return new TokenResponse({ src, error: tokenError });
369
- }
370
- }
371
- // ---------------------------------------------------------------------------
372
- // Akamai response parameter parsing (inlined from CDNtoken/akamaiResponseParameters.js)
373
- // ---------------------------------------------------------------------------
374
- function parseAkamaiResponse({ src, text }) {
375
- const fullUrl = `${src}${text ? `?${text}` : ''}`;
376
- const srcUrl = new URL(fullUrl);
377
- let hdntl = null;
378
- let exp = null;
379
- let hmac = null;
380
- let acl = null;
381
- let hdnea = null;
382
- const params = new URLSearchParams(srcUrl.search);
383
- hdntl = params.get('hdntl');
384
- hdnea = params.get('hdnea');
385
- if (!hdntl && !hdnea) {
386
- const pathSegments = srcUrl.pathname.split('/');
387
- for (const segment of pathSegments) {
388
- if (segment.startsWith('hdntl=')) {
389
- hdntl = segment.substring(6);
390
- break;
391
- }
392
- else if (segment.startsWith('hdnea=')) {
393
- hdnea = segment.substring(6);
394
- break;
395
- }
396
- }
397
- }
398
- const tokenValue = hdntl ?? hdnea;
399
- if (tokenValue) {
400
- const expMatch = tokenValue.match(/exp=(\d+)/);
401
- if (expMatch)
402
- exp = expMatch[1];
403
- const hmacMatch = tokenValue.match(/hmac=([a-f0-9]+)/);
404
- if (hmacMatch)
405
- hmac = hmacMatch[1];
406
- const aclMatch = tokenValue.match(/acl=([^~]+)/);
407
- if (aclMatch)
408
- acl = aclMatch[1];
409
- }
410
- return { expires: exp, hdntl: tokenValue, hmac, acl, hdnea };
411
- }
412
- // ---------------------------------------------------------------------------
413
- // Internal wait helper
414
- // ---------------------------------------------------------------------------
415
- function wait(ms) {
416
- return new Promise((resolve) => { setTimeout(resolve, ms); });
417
- }
418
- // ---------------------------------------------------------------------------
419
- // CDNTokenManager
420
- // ---------------------------------------------------------------------------
421
- class CDNTokenManager {
422
- constructor({ token, tokenType, srcInTokenRequest = false, extraParamsCallback = null, onCDNTokenError, maxRetries = 3, retryInterval = 10000, expirationMarginInSeconds = 30 }) {
423
- const validTypes = Object.values(TOKEN_TYPES);
424
- if (tokenType !== undefined && validTypes.includes(tokenType)) {
425
- this.tokenType = tokenType;
426
- }
427
- else {
428
- this.tokenType = undefined;
429
- }
430
- this.tokenGeneratorUrl = token;
431
- this.srcInTokenRequest = srcInTokenRequest;
432
- if (extraParamsCallback && typeof extraParamsCallback === 'function') {
433
- this.extraParamsCallback = extraParamsCallback;
434
- }
435
- else {
436
- this.extraParamsCallback = null;
437
- }
438
- if (onCDNTokenError && typeof onCDNTokenError === 'function') {
439
- this.callbackError = onCDNTokenError;
440
- }
441
- else {
442
- this.callbackError = null;
443
- }
444
- this.attempt = 0;
445
- this.maxRetries = maxRetries;
446
- this.retryInterval = retryInterval;
447
- this.expirationMarginInSeconds = expirationMarginInSeconds;
448
- this.lastTokenResponse = null;
449
- this.resetAttemptCounterTimeout = null;
450
- }
451
- resetAttemptCounter() {
452
- if (this.resetAttemptCounterTimeout) {
453
- return;
454
- }
455
- this.resetAttemptCounterTimeout = setTimeout(() => {
456
- this.attempt = 0;
457
- }, 30000);
458
- }
459
- // -------------------------------------------------------------------------
460
- // Default flow (Cloudflare / generic query params)
461
- // -------------------------------------------------------------------------
462
- async _fetchDefaultToken(src, lastHttpResponse = null) {
463
- const srcUrl = new URL(src);
464
- ['verify', 'expires'].forEach((param) => srcUrl.searchParams.delete(param));
465
- if (this.attempt >= this.maxRetries) {
466
- this.resetAttemptCounter();
467
- return TokenResponse.fromError({
468
- src: srcUrl.toString(),
469
- error: new TokenError({
470
- status: lastHttpResponse?.status,
471
- errorCode: 'Unauthorized',
472
- errorType: ERROR_TYPES.MAX_RETRY
473
- })
474
- });
475
- }
476
- try {
477
- this.attempt += 1;
478
- let response;
479
- if (this.srcInTokenRequest === true) {
480
- response = await fetch(`${this.tokenGeneratorUrl}?url=${encodeURIComponent(srcUrl.toString())}`);
481
- }
482
- else {
483
- response = await fetch(this.tokenGeneratorUrl);
484
- }
485
- if (response.status === 403) {
486
- await wait(this.retryInterval);
487
- return this._fetchDefaultToken(src, response);
488
- }
489
- if (response.status >= 400) {
490
- return TokenResponse.fromError({
491
- src: srcUrl.toString(),
492
- error: new TokenError({ status: response.status })
493
- });
494
- }
495
- this.attempt = 0;
496
- const responseText = await response.text();
497
- return TokenResponse.fromDefault({ src: srcUrl.toString(), text: responseText });
498
- }
499
- catch (error) {
500
- console.error('CDNToken: Error fetching default token ', error);
501
- return TokenResponse.fromError({
502
- src: srcUrl.toString(),
503
- error: error
504
- });
505
- }
506
- }
507
- // -------------------------------------------------------------------------
508
- // EasyBroadcast / Venom flow
509
- // -------------------------------------------------------------------------
510
- async _fetchEasyBToken(src, lastHttpResponse = null) {
511
- if (this.attempt >= this.maxRetries) {
512
- this.resetAttemptCounter();
513
- return TokenResponse.fromError({
514
- src,
515
- error: new TokenError({
516
- status: lastHttpResponse?.error?.status,
517
- errorCode: (lastHttpResponse?.error?.errorCode) ?? 'Unauthorized',
518
- errorType: ERROR_TYPES.MAX_RETRY
519
- })
520
- });
521
- }
522
- try {
523
- this.attempt += 1;
524
- const headers = {};
525
- if (this.extraParamsCallback) {
526
- const extraParams = await this.extraParamsCallback(lastHttpResponse?.error?.status);
527
- if (extraParams?.accessToken) {
528
- headers.Authorization = `Bearer ${String(extraParams.accessToken)}`;
529
- }
530
- }
531
- else {
532
- console.warn('CDNToken: no extraParamsCallback provided');
533
- }
534
- let response;
535
- if (this.srcInTokenRequest === true) {
536
- response = await fetch(`${this.tokenGeneratorUrl}?url=${encodeURIComponent(src)}`, {
537
- method: 'GET',
538
- headers
539
- });
540
- }
541
- else {
542
- response = await fetch(this.tokenGeneratorUrl, {
543
- method: 'POST',
544
- headers,
545
- body: JSON.stringify({ streamUrl: src })
546
- });
547
- }
548
- if (response.status >= 400) {
549
- const tokenResponse = TokenResponse.fromError({
550
- src,
551
- error: new TokenError({ status: response.status })
552
- });
553
- if (response.status === 403) {
554
- await wait(this.retryInterval);
555
- return this._fetchEasyBToken(src, tokenResponse);
556
- }
557
- return tokenResponse;
558
- }
559
- this.attempt = 0;
560
- const responseText = await response.text();
561
- return TokenResponse.fromText({ src, text: responseText });
562
- }
563
- catch (error) {
564
- console.error('CDNToken: Error fetching token ', error);
565
- return TokenResponse.fromError({ src, error: error });
566
- }
567
- }
568
- // -------------------------------------------------------------------------
569
- // Akamai flow
570
- // -------------------------------------------------------------------------
571
- async _fetchAkamaiToken(src, lastHttpResponse = null) {
572
- if (this.attempt >= this.maxRetries) {
573
- this.resetAttemptCounter();
574
- return TokenResponse.fromError({
575
- src,
576
- error: new TokenError({
577
- status: lastHttpResponse?.status,
578
- errorCode: 'Unauthorized',
579
- errorType: ERROR_TYPES.MAX_RETRY
580
- })
581
- });
582
- }
583
- try {
584
- this.attempt += 1;
585
- const response = await fetch(`${this.tokenGeneratorUrl}?url=${encodeURIComponent(src)}`);
586
- if (response.status === 403) {
587
- await wait(this.retryInterval);
588
- return this._fetchAkamaiToken(src, response);
589
- }
590
- if (response.status >= 400) {
591
- return TokenResponse.fromError({
592
- src,
593
- error: new TokenError({ status: response.status })
594
- });
595
- }
596
- this.attempt = 0;
597
- const responseText = await response.text();
598
- return TokenResponse.fromAkamai({ src, text: responseText });
599
- }
600
- catch (error) {
601
- console.error('CDNToken: Error fetching token ', error);
602
- return TokenResponse.fromError({ src, error: error });
603
- }
604
- }
605
- // -------------------------------------------------------------------------
606
- // Bunny CDN flow
607
- // -------------------------------------------------------------------------
608
- async _fetchBunnyToken(src, tokenResponse = null) {
609
- if (this.attempt >= this.maxRetries) {
610
- this.resetAttemptCounter();
611
- return TokenResponse.fromError({
612
- src,
613
- error: new TokenError({
614
- status: tokenResponse?.error?.status,
615
- errorCode: (tokenResponse?.error?.errorCode) ?? 'Unauthorized',
616
- errorType: ERROR_TYPES.MAX_RETRY
617
- })
618
- });
619
- }
620
- try {
621
- this.attempt += 1;
622
- const headers = {
623
- 'Content-Type': 'application/json'
624
- };
625
- if (this.extraParamsCallback) {
626
- const extraParams = await this.extraParamsCallback(tokenResponse?.error?.status);
627
- if (extraParams?.accessToken) {
628
- headers.Authorization = `Bearer ${String(extraParams.accessToken)}`;
629
- }
630
- }
631
- else {
632
- console.warn('CDNToken: no extraParamsCallback provided');
633
- }
634
- const response = await fetch(this.tokenGeneratorUrl, {
635
- method: 'POST',
636
- headers,
637
- body: JSON.stringify({ streamUrl: src })
638
- });
639
- if (response.status >= 400) {
640
- const contentType = response.headers.get('content-type');
641
- const result = contentType?.includes('application/json')
642
- ? await response.json()
643
- : await response.text();
644
- const errorCode = typeof result === 'object' && result !== null
645
- ? result.error?.errorCode ?? 'Unauthorized'
646
- : 'Unauthorized';
647
- const errorType = typeof result === 'object' && result !== null
648
- ? result.error?.__type ?? ERROR_TYPES.UNKNOWN
649
- : ERROR_TYPES.UNKNOWN;
650
- const tokenError = new TokenError({ status: response.status, errorCode, errorType });
651
- const errorResponse = TokenResponse.fromError({ src, error: tokenError });
652
- if ([403, 401, 498].includes(response.status)) {
653
- if (this.callbackError) {
654
- this.callbackError({
655
- status: tokenError.status,
656
- errorCode: tokenError.errorCode,
657
- errorType: tokenError.errorType
658
- });
659
- }
660
- else {
661
- console.warn(`CDNToken: Bunny token fetch attempt ${this.attempt} for ${src} failed with status ${response.status}, retrying in ${this.retryInterval / 1000}s`);
662
- }
663
- await wait(this.retryInterval);
664
- return this._fetchBunnyToken(src, errorResponse);
665
- }
666
- console.error(`CDNToken: error generating Bunny token on attempt ${this.attempt}`, JSON.stringify(errorResponse));
667
- return errorResponse;
668
- }
669
- // Success
670
- const jsonResponse = await response.json();
671
- this.attempt = 0;
672
- if (Number.isInteger(jsonResponse.max_retries) && jsonResponse.max_retries > 0) {
673
- this.maxRetries = jsonResponse.max_retries;
674
- }
675
- if (Number.isInteger(jsonResponse.retry_interval) && jsonResponse.retry_interval > 0) {
676
- this.retryInterval = jsonResponse.retry_interval * 1000;
677
- }
678
- return TokenResponse.fromJson({ src, json: JSON.stringify(jsonResponse) });
679
- }
680
- catch (error) {
681
- console.error('CDNToken: Error fetching bunny token:', error);
682
- return TokenResponse.fromError({ src, error: error });
683
- }
684
- }
685
- // -------------------------------------------------------------------------
686
- // Public: fetchToken
687
- // -------------------------------------------------------------------------
688
- async fetchToken({ src }) {
689
- if (!src) {
690
- console.warn('CDNToken: Missing src to tokenize');
691
- return null;
692
- }
693
- switch (this.tokenType) {
694
- case TOKEN_TYPES.BUNNY:
695
- this.lastTokenResponse = await this._fetchBunnyToken(src);
696
- break;
697
- case TOKEN_TYPES.AKAMAI:
698
- this.lastTokenResponse = await this._fetchAkamaiToken(src);
699
- break;
700
- case TOKEN_TYPES.VENOM:
701
- case TOKEN_TYPES.EASY_B:
702
- this.lastTokenResponse = await this._fetchEasyBToken(src);
703
- break;
704
- default:
705
- this.lastTokenResponse = await this._fetchDefaultToken(src);
706
- }
707
- return this.lastTokenResponse;
708
- }
709
- // -------------------------------------------------------------------------
710
- // Public: updateUrlWithTokenParams
711
- // -------------------------------------------------------------------------
712
- async updateUrlWithTokenParams({ url }) {
713
- const srcUrl = new URL(url);
714
- const expires = srcUrl.searchParams.get('expires');
715
- switch (this.tokenType) {
716
- case TOKEN_TYPES.AKAMAI: {
717
- const akamaiParams = parseAkamaiResponse({ src: url, text: '' });
718
- if (!this.isTokenExpired(akamaiParams.expires)) {
719
- return url;
720
- }
721
- // Remove hdntl / hdnea path segments before requesting new token
722
- const pathSegments = srcUrl.pathname.split('/');
723
- srcUrl.pathname = pathSegments
724
- .filter((segment) => !segment.startsWith('hdntl=') && !segment.startsWith('hdnea='))
725
- .join('/');
726
- break;
727
- }
728
- case TOKEN_TYPES.EASY_B:
729
- case TOKEN_TYPES.VENOM:
730
- case TOKEN_TYPES.BUNNY: {
731
- if (!this.isTokenExpired(expires)) {
732
- return url;
733
- }
734
- ['token', 'expires', 'token_path'].forEach((param) => srcUrl.searchParams.delete(param));
735
- // Reuse cached lastTokenResponse if still valid
736
- const cachedResponse = this.lastTokenResponse;
737
- if (cachedResponse) {
738
- const cachedParams = cachedResponse.responseParameters;
739
- if (cachedParams && !this.isTokenExpired(cachedParams.expires)) {
740
- const { token, expires: cachedExpires, tokenPath } = cachedParams;
741
- return CDNTokenManager.appendTokenParams(url, token, cachedExpires, tokenPath);
742
- }
743
- }
744
- break;
745
- }
746
- default:
747
- if (!this.isTokenExpired(expires)) {
748
- return url;
749
- }
750
- break;
751
- }
752
- try {
753
- const tokenResponse = await this.fetchToken({ src: srcUrl.toString() });
754
- if (tokenResponse === null) {
755
- return url;
756
- }
757
- if (tokenResponse.error) {
758
- if (this.callbackError) {
759
- this.callbackError({
760
- status: tokenResponse.error.status,
761
- errorCode: tokenResponse.error.errorCode,
762
- errorType: tokenResponse.error.errorType
763
- });
764
- }
765
- return url;
766
- }
767
- switch (this.tokenType) {
768
- case TOKEN_TYPES.BUNNY:
769
- case TOKEN_TYPES.VENOM:
770
- case TOKEN_TYPES.EASY_B: {
771
- const params = tokenResponse.responseParameters;
772
- return CDNTokenManager.appendTokenParams(url, params.token, params.expires, params.tokenPath);
773
- }
774
- case TOKEN_TYPES.AKAMAI: {
775
- const params = tokenResponse.responseParameters;
776
- return CDNTokenManager.appendAkamaiTokenParams(url, params.hdntl ?? params.hdnea ?? '');
777
- }
778
- default: {
779
- return CDNTokenManager.appendDefaultTokenParams(url, tokenResponse);
780
- }
781
- }
782
- }
783
- catch (error) {
784
- console.error('CDNToken: Error while fetching new token:', error);
785
- return url;
786
- }
787
- }
788
- // -------------------------------------------------------------------------
789
- // Public: isTokenExpired
790
- // -------------------------------------------------------------------------
791
- isTokenExpired(expirationTime) {
792
- if (expirationTime === null || expirationTime === undefined)
793
- return true;
794
- const currentTimeInSeconds = Math.floor(Date.now() / 1000) + this.expirationMarginInSeconds;
795
- if (Number(expirationTime) < currentTimeInSeconds) {
796
- return true;
797
- }
798
- return false;
799
- }
800
- // -------------------------------------------------------------------------
801
- // Public: destroy
802
- // -------------------------------------------------------------------------
803
- destroy() {
804
- if (this.resetAttemptCounterTimeout) {
805
- clearTimeout(this.resetAttemptCounterTimeout);
806
- this.resetAttemptCounterTimeout = null;
807
- }
808
- this.attempt = 0;
809
- this.lastTokenResponse = null;
810
- console.log('CDNTokenManager: destroyed');
811
- }
812
- // -------------------------------------------------------------------------
813
- // Static helpers
814
- // -------------------------------------------------------------------------
815
- static appendTokenParams(urlString, token, expires, tokenPath) {
816
- const url = new URL(urlString);
817
- if (token) {
818
- url.searchParams.set('token', token);
819
- if (expires !== null)
820
- url.searchParams.set('expires', String(expires));
821
- if (tokenPath)
822
- url.searchParams.set('token_path', tokenPath);
823
- }
824
- return url.toString();
825
- }
826
- static appendAkamaiTokenParams(urlString, hdnea) {
827
- const url = new URL(urlString);
828
- url.searchParams.delete('hdnea');
829
- url.searchParams.delete('hdntl');
830
- url.searchParams.set('hdnea', hdnea);
831
- return url.toString();
832
- }
833
- static appendDefaultTokenParams(urlString, tokenResponse) {
834
- const url = new URL(urlString);
835
- const params = tokenResponse.responseParameters;
836
- if (params?.verify) {
837
- url.searchParams.set('verify', params.verify);
838
- }
839
- if (params?.expires !== null && params?.expires !== undefined) {
840
- url.searchParams.set('expires', String(params.expires));
841
- }
842
- return url.toString();
843
- }
844
- }
845
-
846
- /**
847
- * DrmConfigurator - Builds HLS DRM configuration objects.
848
- *
849
- * Isolates DRM complexity from engine initialization code.
850
- * Ported from component/engines/hls.js init method (lines ~237-268).
851
- *
852
- * The licenseXhrSetup hook:
853
- * 1. Calls tokenManager.extraParamsCallback() for auth token (if configured)
854
- * 2. Sets Authorization: Bearer <accessToken> on the XHR request
855
- * 3. Appends drm_custom_data from tokenManager.lastTokenResponse.extras to URL
856
- *
857
- * CRITICAL (from MEMORY.md): The licenseXhrSetup hook captures tokenManager
858
- * via closure reference. The tokenManager is NEVER stored in PlayerState.
859
- * DrmConfigurator holds a direct class-property reference.
860
- */
861
- class DrmConfigurator {
862
- constructor(tokenManager) {
863
- this.tokenManager = tokenManager;
864
- }
865
- /**
866
- * Build the DRM-related fields for the HLS.js constructor config.
867
- *
868
- * Returns an empty object when emeEnabled is not set (no DRM).
869
- * Returns emeEnabled, drmSystems, and licenseXhrSetup when DRM is configured.
870
- */
871
- buildHlsConfig(engineSettings) {
872
- if (!engineSettings.emeEnabled) {
873
- return {};
874
- }
875
- const tokenManager = this.tokenManager;
876
- return {
877
- emeEnabled: engineSettings.emeEnabled,
878
- ...(engineSettings.drmSystems !== undefined && { drmSystems: engineSettings.drmSystems }),
879
- licenseXhrSetup: async (xhr, url) => {
880
- // Step 1: Inject Authorization header from extraParamsCallback (if available)
881
- if (tokenManager?.extraParamsCallback) {
882
- const extraParams = await tokenManager.extraParamsCallback().catch((error) => {
883
- console.error('DrmConfigurator: extraParamsCallback error:', error);
884
- return null;
885
- });
886
- if (extraParams?.accessToken) {
887
- xhr.setRequestHeader('Authorization', `Bearer ${extraParams.accessToken}`);
888
- }
889
- }
890
- // Step 2: Append drm_custom_data from lastTokenResponse.extras to URL
891
- const customData = tokenManager?.lastTokenResponse?.extras?.drm_custom_data;
892
- if (customData) {
893
- const urlObject = new URL(url);
894
- urlObject.searchParams.delete('customdata'); // remove existing customdata if any
895
- urlObject.searchParams.append('customdata', customData);
896
- xhr.open('POST', urlObject.toString(), true);
897
- }
898
- else {
899
- xhr.open('POST', url, true);
900
- }
901
- }
902
- };
903
- }
904
- }
905
-
906
- /**
907
- * HLS retry handler.
908
- *
909
- * Registers an hlsError listener on the hls.js driver and attempts
910
- * automatic recovery:
911
- * - Fatal mediaError: calls recoverMediaError() after 1s
912
- * - Fatal other error: calls startLoad() after 1s
913
- * - Non-fatal error: re-checks after 3s; if currentTime has not advanced
914
- * (and Chromecast is not casting), applies the same recovery
915
- */
916
- function handleHlsRetry(driver, engine) {
917
- driver.on('hlsError', (event, data) => {
918
- console.warn(`HLS Retry: Error occured; ${data.fatal ? 'fatal, trying to fix now' : 'non fatal, monitor it'}.`, event, data);
919
- if (data.fatal) {
920
- setTimeout(() => {
921
- if (data.type === 'mediaError')
922
- driver.recoverMediaError();
923
- else
924
- driver.startLoad();
925
- }, 1000);
926
- }
927
- else {
928
- const errorTime = driver.media?.currentTime;
929
- setTimeout(() => {
930
- if (errorTime === driver.media?.currentTime && !engine?.chromecast_casting) {
931
- console.warn('HLS Retry: Looks like stream is stuck, trying to fix', event, data);
932
- if (data?.type === 'mediaError')
933
- driver?.recoverMediaError();
934
- else
935
- driver.startLoad();
936
- }
937
- else
938
- console.info('HLS Retry: Stream is playing so this is fine', event, data);
939
- }, 3000);
940
- }
941
- });
942
- }
943
-
944
- /**
945
- * Workaround for hls.js bug: audio-stream-controller deadlocks when
946
- * the audio playlist has more #EXT-X-DISCONTINUITY tags than video.
947
- * Two symptoms:
948
- * 1. WAITING_TRACK deadlock: onAudioTrackLoaded bails when
949
- * audio endCC > video endCC, caching data forever.
950
- * 2. WAITING_INIT_PTS loop: audio fragments have CC values that
951
- * the video never produces initPTS for (e.g., audio CC=4,
952
- * video only has CC up to 2).
953
- * Fix: remap audio fragment CC values and endCC to the video's
954
- * CC range so both checks pass. The actual time alignment uses
955
- * PDT (EXT-X-PROGRAM-DATE-TIME) as fallback.
956
- * See https://github.com/video-dev/hls.js/issues/1484
957
- *
958
- * IMPORTANT: Must be called BEFORE driver.attachMedia() / driver.loadSource()
959
- * (Pitfall 4: trigger is set up during construction, so patching after
960
- * media attach may miss early events).
961
- */
962
- function applyDiscontinuityWorkaround(driver, hlsEvents) {
963
- const origTrigger = driver.trigger;
964
- driver.trigger = function (event, eventObject) {
965
- if (event === hlsEvents.AUDIO_TRACK_LOADED) {
966
- const eventData = eventObject;
967
- if (eventData?.details) {
968
- const levelIdx = this.currentLevel >= 0 ? this.currentLevel : 0;
969
- const mainDetails = this.levels?.[levelIdx]?.details;
970
- if (mainDetails && eventData.details.endCC > mainDetails.endCC) {
971
- const mainEndCC = mainDetails.endCC;
972
- const frags = eventData.details.fragments;
973
- if (frags) {
974
- for (let index = 0; index < frags.length; index++) {
975
- if (frags[index].cc > mainEndCC) {
976
- frags[index].cc = mainEndCC;
977
- }
978
- }
979
- }
980
- if (eventData.details.fragmentHint?.cc !== undefined && eventData.details.fragmentHint.cc > mainEndCC) {
981
- eventData.details.fragmentHint.cc = mainEndCC;
982
- }
983
- eventData.details.endCC = mainEndCC;
984
- }
985
- }
986
- }
987
- return origTrigger.call(this, event, eventObject);
988
- };
989
- }
990
-
991
- /**
992
- * HlsEngine — HLS playback engine.
993
- *
994
- * Ported from component/engines/hls.js
995
- *
996
- * Extends BaseEngine to bridge hls.js driver events to PlayerState.
997
- * Uses CDN loader, CDNTokenManager, DrmConfigurator, StallWatchdog,
998
- * retry handler, and discontinuity workaround.
999
- *
1000
- * CRITICAL: driver instance is a private class field — NEVER stored in PlayerState.
1001
- * CRITICAL: Call applyDiscontinuityWorkaround BEFORE driver.attachMedia/loadSource (Pitfall 4).
1002
- * CRITICAL: Never call driver.startLoad() from video 'play' event handler (Pitfall 3).
1003
- */
1004
- // ---------------------------------------------------------------------------
1005
- // HlsEngine
1006
- // ---------------------------------------------------------------------------
1007
- class HlsEngine extends BaseEngine {
1008
- constructor() {
1009
- super(...arguments);
1010
- // Private — NEVER in PlayerState (Pitfall 2)
1011
- this.driver = null;
1012
- this.tokenManager = null;
1013
- // Holds state reference for named driver event handlers
1014
- this.eventState = null;
1015
- }
1016
- // -------------------------------------------------------------------------
1017
- // BaseEngine hooks
1018
- // -------------------------------------------------------------------------
1019
- async onAttach() {
1020
- if (!this.state)
1021
- return;
1022
- // Pitfall 1: set loading state first, before any async work
1023
- this.state.playbackState = 'loading';
1024
- try {
1025
- await this.init();
1026
- }
1027
- catch (error) {
1028
- console.error('HlsEngine: init failed', error);
1029
- if (this.state) {
1030
- this.state.error = error instanceof Error ? error.message : String(error);
1031
- }
1032
- }
1033
- }
1034
- onDetach() {
1035
- this.stopWatchdog();
1036
- if (this.driver !== null) {
1037
- try {
1038
- this.driver.detachMedia();
1039
- this.driver.destroy();
1040
- }
1041
- catch (error) {
1042
- console.error('HlsEngine: error during driver teardown', error);
1043
- }
1044
- this.driver = null;
1045
- }
1046
- this.tokenManager = null;
1047
- this.eventState = null;
1048
- }
1049
- recoverFromStall(attempt) {
1050
- if (!this.driver)
1051
- return;
1052
- if (attempt === 1) {
1053
- console.warn('HlsEngine stall recovery: recoverMediaError');
1054
- this.driver.recoverMediaError();
1055
- }
1056
- else {
1057
- console.warn('HlsEngine stall recovery: startLoad');
1058
- this.driver.startLoad(this.video?.currentTime ?? -1);
1059
- }
1060
- }
1061
- // -------------------------------------------------------------------------
1062
- // Initialisation
1063
- // -------------------------------------------------------------------------
1064
- async init() {
1065
- const { config, video, state, signal } = this;
1066
- if (!config || !video || !state || !signal)
1067
- return;
1068
- const hlsjsUrl = config.hlsjs;
1069
- if (!hlsjsUrl) {
1070
- throw new Error('HlsEngine: config.hlsjs URL is required');
1071
- }
1072
- // Create token manager if a token URL is configured
1073
- if (config.token) {
1074
- this.tokenManager = new CDNTokenManager({
1075
- token: config.token,
1076
- tokenType: config.tokenType,
1077
- srcInTokenRequest: config.srcInTokenRequest,
1078
- extraParamsCallback: config.extraParamsCallback,
1079
- onCDNTokenError: config.engineSettings.onCDNTokenError
1080
- });
1081
- // Fetch initial token
1082
- if (config.src) {
1083
- await this.tokenManager.fetchToken({ src: config.src });
1084
- }
1085
- }
1086
- console.info('HlsEngine: loading hls.js from', hlsjsUrl);
1087
- const Hls = await loadScript(hlsjsUrl, 'Hls');
1088
- // Build DRM config
1089
- const drmConfigurator = new DrmConfigurator(this.tokenManager);
1090
- const drmConfig = drmConfigurator.buildHlsConfig(config.engineSettings);
1091
- // Capture tokenManager for closure use in pLoader (Pitfall 6)
1092
- const tokenManager = this.tokenManager;
1093
- // Build pLoader class for token-injected manifest requests
1094
- // pLoader captures tokenManager via closure to avoid `this` binding issues
1095
- class PLoader extends Hls.DefaultConfig.loader {
1096
- constructor(loaderConfig) {
1097
- super(loaderConfig);
1098
- const originalLoad = this.load.bind(this);
1099
- this.load = async function (context, loadConfig, callbacks) {
1100
- if (!context.url || !tokenManager) {
1101
- originalLoad(context, loadConfig, callbacks);
1102
- return;
1103
- }
1104
- // Only handle .m3u8 manifest URLs
1105
- const parsedUrl = new URL(context.url);
1106
- const lastSegment = parsedUrl.pathname.split('/').pop();
1107
- if (!lastSegment.endsWith('.m3u8')) {
1108
- originalLoad(context, loadConfig, callbacks);
1109
- return;
1110
- }
1111
- // Refresh token params if expired
1112
- context.url = await tokenManager.updateUrlWithTokenParams({ url: context.url });
1113
- originalLoad(context, loadConfig, callbacks);
1114
- };
1115
- }
1116
- }
1117
- // Build driver config
1118
- const engineSettings = config.engineSettings;
1119
- const driverConfig = {
1120
- autoStartLoad: !config.startAt,
1121
- enableWorker: true,
1122
- backBufferLength: 30,
1123
- ...engineSettings,
1124
- ...drmConfig,
1125
- startPosition: config.startAt,
1126
- pLoader: PLoader
1127
- };
1128
- // Inject custom ABR controller unless disabled
1129
- if (!config.disableCustomAbr) {
1130
- try {
1131
- const { createHlsAbrController } = await Promise.resolve().then(function () { return hls; });
1132
- driverConfig.abrController = createHlsAbrController(Hls);
1133
- }
1134
- catch (abrError) {
1135
- console.warn('HlsEngine: failed to load custom ABR controller, using default', abrError);
1136
- }
1137
- }
1138
- // Create the driver (NEVER stored in state)
1139
- const driver = new Hls(driverConfig);
1140
- this.driver = driver;
1141
- // Pitfall 4: apply discontinuity workaround BEFORE attachMedia/loadSource
1142
- applyDiscontinuityWorkaround(driver, Hls.Events);
1143
- // Wire retry handler
1144
- if (config.retry) {
1145
- handleHlsRetry(driver);
1146
- }
1147
- // Bind native video element events to state
1148
- this.bindVideoEvents(video, state, signal);
1149
- // Attach media and load source
1150
- driver.attachMedia(video);
1151
- // Build source URL with token params if applicable
1152
- let src = config.src ?? '';
1153
- if (this.tokenManager && src) {
1154
- src = await this.tokenManager.updateUrlWithTokenParams({ url: src });
1155
- }
1156
- driver.loadSource(src);
1157
- // Register driver event handlers
1158
- this.registerDriverEvents(Hls, state);
1159
- // Start stall watchdog
1160
- this.startWatchdog();
1161
- }
1162
- // -------------------------------------------------------------------------
1163
- // Driver event mapping
1164
- // -------------------------------------------------------------------------
1165
- registerDriverEvents(Hls, state) {
1166
- const driver = this.driver;
1167
- if (!driver)
1168
- return;
1169
- this.eventState = state;
1170
- const { Events } = Hls;
1171
- driver.on(Events.MANIFEST_LOADED, this._onManifestLoaded.bind(this));
1172
- driver.on(Events.LEVEL_LOADED, this._onLevelLoaded.bind(this));
1173
- driver.on(Events.LEVEL_SWITCHED, this._onLevelSwitched.bind(this));
1174
- driver.on(Events.AUDIO_TRACKS_UPDATED, this._onAudioTracksUpdated.bind(this));
1175
- driver.on(Events.AUDIO_TRACK_SWITCHED, this._onAudioTrackSwitched.bind(this));
1176
- driver.on(Events.SUBTITLE_TRACKS_UPDATED, this._onSubtitleTracksUpdated.bind(this));
1177
- driver.on(Events.SUBTITLE_TRACK_SWITCH, this._onSubtitleTrackSwitch.bind(this));
1178
- }
1179
- _onManifestLoaded(_event, data) {
1180
- const state = this.eventState;
1181
- const driver = this.driver;
1182
- if (!state)
1183
- return;
1184
- const manifestData = data;
1185
- const levels = manifestData.levels ?? driver?.levels ?? [];
1186
- state.qualityLevels = levels.map((level, index) => ({
1187
- name: level.name ?? `${level.height}p`,
1188
- id: index,
1189
- bitrate: level.bitrate,
1190
- height: level.height,
1191
- width: level.width
1192
- }));
1193
- }
1194
- _onLevelLoaded(_event, data) {
1195
- const state = this.eventState;
1196
- if (!state)
1197
- return;
1198
- const levelData = data;
1199
- if (levelData?.details?.live !== undefined) {
1200
- state.isLive = levelData.details.live;
1201
- }
1202
- }
1203
- _onLevelSwitched(_event, data) {
1204
- const state = this.eventState;
1205
- if (!state)
1206
- return;
1207
- const switchedData = data;
1208
- state.currentQuality = switchedData.level;
1209
- }
1210
- _onAudioTracksUpdated(_event, data) {
1211
- const state = this.eventState;
1212
- const driver = this.driver;
1213
- if (!state)
1214
- return;
1215
- const audioData = data;
1216
- const tracks = audioData.audioTracks ?? driver?.audioTracks ?? [];
1217
- state.audioTracks = tracks.map((track) => ({
1218
- id: track.id,
1219
- name: track.name,
1220
- lang: track.lang
1221
- }));
1222
- }
1223
- _onAudioTrackSwitched(_event, data) {
1224
- const state = this.eventState;
1225
- if (!state)
1226
- return;
1227
- const switchData = data;
1228
- state.currentAudioTrack = switchData.id;
1229
- }
1230
- _onSubtitleTracksUpdated(_event, data) {
1231
- const state = this.eventState;
1232
- const driver = this.driver;
1233
- if (!state)
1234
- return;
1235
- const subtitleData = data;
1236
- const tracks = subtitleData.subtitleTracks ?? driver?.subtitleTracks ?? [];
1237
- state.subtitleTracks = tracks.map((track) => ({
1238
- id: track.id,
1239
- name: track.name,
1240
- lang: track.lang
1241
- }));
1242
- }
1243
- _onSubtitleTrackSwitch(_event, data) {
1244
- const state = this.eventState;
1245
- if (!state)
1246
- return;
1247
- const switchData = data;
1248
- state.currentSubtitleTrack = switchData.id;
1249
- }
1250
- }
1251
-
1252
- /**
1253
- * DASH retry settings.
1254
- *
1255
- * Applies infinite retry configuration to the dash.js driver so that
1256
- * transient network failures (segment fetch, manifest re-fetch, etc.)
1257
- * never permanently stop playback.
1258
- */
1259
- function applyDashRetrySettings(driver) {
1260
- driver.updateSettings({
1261
- streaming: {
1262
- retryIntervals: {
1263
- MPD: 1000,
1264
- XLinkExpansion: 1000,
1265
- InitializationSegment: 1000,
1266
- IndexSegment: 1000,
1267
- MediaSegment: 1000,
1268
- BitstreamSwitchingSegment: 1000,
1269
- other: 1000,
1270
- lowLatencyReductionFactor: 1
1271
- }
1272
- }
1273
- });
1274
- driver.updateSettings({
1275
- streaming: {
1276
- retryAttempts: {
1277
- MPD: Infinity,
1278
- XLinkExpansion: Infinity,
1279
- InitializationSegment: Infinity,
1280
- IndexSegment: Infinity,
1281
- MediaSegment: Infinity,
1282
- BitstreamSwitchingSegment: Infinity,
1283
- other: Infinity,
1284
- lowLatencyMultiplyFactor: 1
1285
- }
1286
- }
1287
- });
1288
- }
1289
-
1290
- /**
1291
- * DASH Custom ABR Rule
1292
- *
1293
- * Ported from component/engines/abr/dash.js
1294
- *
1295
- * Implements a buffer-and-throughput aware adaptive bitrate rule for dash.js.
1296
- * Applied via driver.addABRCustomRule('qualitySwitchRules', 'CustomAbrRule', factory).
1297
- */
1298
- const MAX_BUFFER_LENGTH = 15;
1299
- const PAST_BUFFER_FACTOR = 3;
1300
- // ---------------------------------------------------------------------------
1301
- // Internal helpers
1302
- // ---------------------------------------------------------------------------
1303
- const getAvgBandwidth = (requests, totalTime) => {
1304
- const [{ type, _tfinish, trequest, tresponse, trace }] = requests[0];
1305
- return type !== 'MediaSegment' && _tfinish && trequest && tresponse && trace && trace.length
1306
- ? trace.reduce((accumulator, traceEntry) => accumulator + traceEntry.b[0], 0) * 8 / totalTime
1307
- : 0;
1308
- };
1309
- const findNextBestLevel = (standardDeviation, rulesContext, avgBitrate, dashMetrics, downloadTime, count) => {
1310
- if (count <= 0)
1311
- return undefined;
1312
- const [{ bandwidth: currentBitrate }, { bandwidth: nextBitrate }] = rulesContext.getMediaInfo().bitrateList;
1313
- if ((count > 2) && (avgBitrate * 0.8 >= currentBitrate) && (avgBitrate <= nextBitrate)) {
1314
- return (standardDeviation < 8) ? count : count - 1;
1315
- }
1316
- if (count === 1) {
1317
- return (dashMetrics.getCurrentBufferLevel('video', true) > downloadTime * 1.5 && standardDeviation < 8)
1318
- ? count
1319
- : count - 1;
1320
- }
1321
- return undefined;
1322
- };
1323
- // ---------------------------------------------------------------------------
1324
- // createDashAbrRule
1325
- // ---------------------------------------------------------------------------
1326
- function createDashAbrRule(dashjs) {
1327
- const SwitchRequest = dashjs.FactoryMaker.getClassFactoryByName('SwitchRequest');
1328
- const StreamController = dashjs.FactoryMaker.getSingletonFactoryByName('StreamController');
1329
- const DashMetrics = dashjs.FactoryMaker.getSingletonFactoryByName('DashMetrics');
1330
- const addBufferValues = new Set();
1331
- let lastLoadedFragLevel = 0;
1332
- let lastBufferValue = 15;
1333
- let minBufferValue = 15;
1334
- const setBufferLength = (value) => {
1335
- /* istanbul ignore next */
1336
- if (value === undefined)
1337
- return;
1338
- if (lastBufferValue < value) {
1339
- addBufferValues.add(minBufferValue);
1340
- lastBufferValue = value;
1341
- minBufferValue = value;
1342
- }
1343
- if (value < minBufferValue) {
1344
- minBufferValue = value;
1345
- lastBufferValue = minBufferValue;
1346
- }
1347
- };
1348
- const getBestLevel = ({ requests, count, rulesContext, currentValue, dashMetrics }) => {
1349
- /* istanbul ignore next */
1350
- if (!requests)
1351
- return [''];
1352
- let lastRequest = null;
1353
- for (let index = requests.length - 1; (index >= 0) && (lastRequest === null); index--) {
1354
- const [{ _tfinish, trequest, tresponse, trace }] = requests[index];
1355
- if (_tfinish && trequest && tresponse && trace && trace.length) {
1356
- lastRequest = requests[index];
1357
- }
1358
- }
1359
- if (lastRequest === null)
1360
- return ['No valid request made for this stream yet, bailing'];
1361
- const totalTime = (lastRequest[0]._tfinish.getTime() - lastRequest[0].trequest.getTime()) / 1000;
1362
- const downloadTime = (lastRequest[0]._tfinish.getTime() - lastRequest[0].tresponse.getTime()) / 1000;
1363
- if (lastRequest[0].type === 'InitializationSegment' || lastRequest[0].type === 'MediaSegment') {
1364
- if (totalTime <= 0) {
1365
- return ['Last segment not downloaded, probable network problem: Switching to lowest quality', 0];
1366
- }
1367
- if (addBufferValues.size >= PAST_BUFFER_FACTOR) {
1368
- const valuesArray = Array.from(addBufferValues);
1369
- const avgBuffer = valuesArray.reduce((acc, val) => acc + val, 0) / addBufferValues.size;
1370
- const avgBitrate = getAvgBandwidth(requests, totalTime);
1371
- const sdBuffer = Math.sqrt(valuesArray.map((value) => (value - avgBuffer) ** 2).reduce((acc, val) => acc + val, 0) / addBufferValues.size);
1372
- addBufferValues.clear();
1373
- if (avgBuffer <= MAX_BUFFER_LENGTH * 0.1)
1374
- return ['Avg. Buffer < 10% of max buffer, switching to lowest quality', 0];
1375
- if (avgBuffer <= MAX_BUFFER_LENGTH * 0.3)
1376
- return ['Maintaining the current Bitrate', currentValue];
1377
- if ((avgBuffer <= MAX_BUFFER_LENGTH * 0.7) && (rulesContext.getMediaInfo().bitrateList[currentValue].bandwidth < avgBitrate)) {
1378
- if ((lastLoadedFragLevel < count - 1) || (sdBuffer <= 8)) {
1379
- return [
1380
- 'Switching to optimised quality level',
1381
- !lastLoadedFragLevel
1382
- ? lastLoadedFragLevel + 1
1383
- : findNextBestLevel(sdBuffer, rulesContext, avgBitrate, dashMetrics, downloadTime, count)
1384
- ];
1385
- }
1386
- return [
1387
- 'Switching to optimised quality level',
1388
- findNextBestLevel(sdBuffer, rulesContext, avgBitrate, dashMetrics, downloadTime, count)
1389
- ];
1390
- }
1391
- return [
1392
- 'Switching to optimised quality level',
1393
- findNextBestLevel(sdBuffer, rulesContext, avgBitrate, dashMetrics, downloadTime, count)
1394
- ];
1395
- }
1396
- return ['Maintaining the last loaded level'];
1397
- }
1398
- return ['Last Loaded segment was not a media segment of initialization segment'];
1399
- };
1400
- // The ABR rule factory function — assigned __dashjs_factory_name before passing to getClassFactory
1401
- const customAbr = function () {
1402
- const getMaxIndex = (rulesContext) => {
1403
- const switchRequest = SwitchRequest(this.context).create();
1404
- switchRequest.priority = SwitchRequest.PRIORITY.STRONG;
1405
- const mediaType = rulesContext.getMediaInfo().type;
1406
- const dashMetrics = DashMetrics(this.context).getInstance();
1407
- const streamController = StreamController(this.context).getInstance();
1408
- const abrController = rulesContext.getAbrController();
1409
- // Sample buffer level every second for adaptive decision
1410
- setInterval(() => {
1411
- setBufferLength(dashMetrics.getCurrentBufferLevel('video', true));
1412
- }, 1000);
1413
- if (mediaType === 'video') {
1414
- let level = 0;
1415
- let reason;
1416
- try {
1417
- const [resultReason, resultLevel] = getBestLevel({
1418
- requests: dashMetrics.getHttpRequests(mediaType),
1419
- count: rulesContext.getMediaInfo().representationCount,
1420
- currentValue: abrController.getQualityFor(mediaType, streamController.getActiveStreamInfo()),
1421
- rulesContext,
1422
- dashMetrics
1423
- });
1424
- reason = resultReason;
1425
- level = resultLevel ?? lastLoadedFragLevel;
1426
- lastLoadedFragLevel = level;
1427
- }
1428
- catch {
1429
- addBufferValues.clear();
1430
- return { quality: 0, reason: 'Error in ABR rule', priority: SwitchRequest.PRIORITY.STRONG };
1431
- }
1432
- switchRequest.quality = level;
1433
- switchRequest.reason = reason;
1434
- return switchRequest;
1435
- }
1436
- switchRequest.quality = 0;
1437
- switchRequest.reason = 'Audio Request';
1438
- return switchRequest;
1439
- };
1440
- return { getMaxIndex };
1441
- };
1442
- customAbr.__dashjs_factory_name = 'CustomAbrRule';
1443
- return dashjs.FactoryMaker.getClassFactory(customAbr);
1444
- }
1445
-
1446
- /**
1447
- * DashEngine — DASH playback engine.
1448
- *
1449
- * Ported from component/engines/dash.js
1450
- *
1451
- * Extends BaseEngine to bridge dash.js MediaPlayer events to PlayerState.
1452
- * Uses CDN loader, CDNTokenManager, StallWatchdog, and DASH retry settings.
1453
- *
1454
- * CRITICAL: driver instance is a private class field — NEVER stored in PlayerState.
1455
- */
1456
- // ---------------------------------------------------------------------------
1457
- // DashEngine
1458
- // ---------------------------------------------------------------------------
1459
- class DashEngine extends BaseEngine {
1460
- constructor() {
1461
- super(...arguments);
1462
- // Private — NEVER in PlayerState
1463
- this.driver = null;
1464
- this.dvrErrorHandler = null;
1465
- }
1466
- // Set by the factory that wires up engine with config and bus before attach()
1467
- setConfig(config) {
1468
- this.config = config;
1469
- }
1470
- setBus(bus) {
1471
- this.bus = bus;
1472
- }
1473
- setVideo(video) {
1474
- this.video = video;
1475
- }
1476
- // -------------------------------------------------------------------------
1477
- // BaseEngine hooks
1478
- // -------------------------------------------------------------------------
1479
- async onAttach() {
1480
- if (!this.state)
1481
- return;
1482
- this.state.playbackState = 'loading';
1483
- try {
1484
- await this.init();
1485
- }
1486
- catch (error) {
1487
- console.error('DashEngine: init failed', error);
1488
- if (this.state) {
1489
- this.state.error = error instanceof Error ? error.message : String(error);
1490
- }
1491
- }
1492
- }
1493
- onDetach() {
1494
- this.stopWatchdog();
1495
- if (this.driver !== null) {
1496
- try {
1497
- this.driver.reset();
1498
- this.driver.destroy();
1499
- }
1500
- catch (error) {
1501
- console.error('DashEngine: error during driver teardown', error);
1502
- }
1503
- this.driver = null;
1504
- }
1505
- // DVR error handler cleanup — AbortSignal also handles it but be explicit
1506
- /* istanbul ignore next */
1507
- if (this.dvrErrorHandler !== null) {
1508
- window.removeEventListener('unhandledrejection', this.dvrErrorHandler);
1509
- this.dvrErrorHandler = null;
1510
- }
1511
- }
1512
- recoverFromStall(attempt) {
1513
- if (this.video === null)
1514
- return;
1515
- if (attempt === 1) {
1516
- console.warn('DashEngine stall recovery: seek nudge');
1517
- this.video.currentTime += 0.1;
1518
- }
1519
- else {
1520
- console.warn('DashEngine stall recovery: refreshManifest');
1521
- this.driver?.refreshManifest();
1522
- }
1523
- }
1524
- // -------------------------------------------------------------------------
1525
- // Initialisation
1526
- // -------------------------------------------------------------------------
1527
- async init() {
1528
- const { config, video, state, signal } = this;
1529
- if (!config || !video || !state || !signal)
1530
- return;
1531
- const dashjsUrl = config.dashjs;
1532
- if (!dashjsUrl) {
1533
- throw new Error('DashEngine: config.dashjs URL is required');
1534
- }
1535
- // Register DVR error suppression handler
1536
- // dash.js 4.7.4 throws unhandled rejections containing 'getCurrentDVRInfo'
1537
- this.dvrErrorHandler = (event) => {
1538
- const reason = event.reason;
1539
- if (reason?.message?.includes('getCurrentDVRInfo') ||
1540
- String(event.reason).includes('getCurrentDVRInfo')) {
1541
- event.preventDefault();
1542
- console.warn('DashEngine: suppressed DVR unhandledrejection');
1543
- }
1544
- };
1545
- // Register with AbortSignal so it auto-removes when engine is detached
1546
- window.addEventListener('unhandledrejection', this.dvrErrorHandler, { signal });
1547
- console.info('DashEngine: loading dashjs from', dashjsUrl);
1548
- const dashjs = await loadScript(dashjsUrl, 'dashjs');
1549
- const player = dashjs.MediaPlayer().create();
1550
- if (!player) {
1551
- throw new Error('DashEngine: dash.js MediaPlayer could not be created');
1552
- }
1553
- this.driver = player;
1554
- player.initialize(video, config.src ?? '', config.autoplay ?? false);
1555
- // Apply retry settings if requested
1556
- if (config.retry) {
1557
- applyDashRetrySettings(player);
1558
- }
1559
- // Apply custom ABR rule unless disabled
1560
- if (!config.disableCustomAbr) {
1561
- const abrRule = createDashAbrRule(dashjs);
1562
- player.addABRCustomRule('qualitySwitchRules', 'CustomAbrRule', abrRule);
1563
- }
1564
- // Low-latency settings
1565
- if (config.lowLatency) {
1566
- player.updateSettings({
1567
- streaming: {
1568
- lowLatencyEnabled: true,
1569
- liveDelay: 30,
1570
- liveCatchUpPlaybackRate: 0.01
1571
- }
1572
- });
1573
- }
1574
- // Wire driver events to PlayerState
1575
- this.registerDriverEvents(dashjs, state, player);
1576
- // Bind native video events
1577
- this.bindVideoEvents(video, state, signal);
1578
- // Start the stall watchdog
1579
- this.startWatchdog();
1580
- // Wait for stream to be initialised before marking ready
1581
- await new Promise((resolve) => {
1582
- const onInit = () => {
1583
- player.off(dashjs.MediaPlayer.events['STREAM_INITIALIZED'], onInit);
1584
- resolve();
1585
- };
1586
- player.on(dashjs.MediaPlayer.events['STREAM_INITIALIZED'], onInit);
1587
- });
1588
- }
1589
- // -------------------------------------------------------------------------
1590
- // Event mapping
1591
- // -------------------------------------------------------------------------
1592
- registerDriverEvents(dashjs, state, player) {
1593
- const events = dashjs.MediaPlayer.events;
1594
- // STREAM_INITIALIZED: populate quality levels, audio tracks, subtitle tracks, duration, isLive
1595
- player.on(events['STREAM_INITIALIZED'], () => {
1596
- const bitrateList = player.getBitrateInfoListFor('video');
1597
- state.qualityLevels = bitrateList.map((bitrateInfo, index) => ({
1598
- name: `${bitrateInfo.height}p`,
1599
- id: index,
1600
- bitrate: bitrateInfo.bitrate
1601
- }));
1602
- const audioTracks = player.getTracksFor('audio');
1603
- state.audioTracks = audioTracks.map((track) => ({
1604
- name: track.lang ?? '',
1605
- id: track.id ?? track.index ?? 0
1606
- }));
1607
- const subtitleTracks = player.getTracksFor('fragmentedText');
1608
- state.subtitleTracks = subtitleTracks.map((track) => ({
1609
- name: track.lang ?? '',
1610
- id: track.id ?? track.index ?? 0
1611
- }));
1612
- const rawDuration = player.duration();
1613
- state.duration = Number.isFinite(rawDuration) ? Math.ceil(rawDuration) : 0;
1614
- // Determine live: if getCurrentLiveLatency() returns a valid number the stream is live
1615
- const liveLatency = player.getCurrentLiveLatency();
1616
- if (typeof this.config?.isLive === 'boolean') {
1617
- state.isLive = this.config.isLive;
1618
- }
1619
- else {
1620
- state.isLive = !Number.isNaN(liveLatency);
1621
- }
1622
- state.playbackState = 'playing';
1623
- });
1624
- // PLAYBACK_TIME_UPDATED: update currentTime
1625
- player.on(events['PLAYBACK_TIME_UPDATED'], () => {
1626
- state.currentTime = Math.round(player.time());
1627
- });
1628
- // QUALITY_CHANGE_REQUESTED: update currentQuality
1629
- player.on(events['QUALITY_CHANGE_REQUESTED'], () => {
1630
- state.currentQuality = player.getQualityFor('video');
1631
- });
1632
- // TRACK_CHANGE_RENDERED: update currentAudioTrack
1633
- player.on(events['TRACK_CHANGE_RENDERED'], () => {
1634
- state.currentAudioTrack = player.getQualityFor('audio');
1635
- });
1636
- // ERROR: log dash.js errors
1637
- player.on(events['ERROR'], (eventData) => {
1638
- const typedData = eventData;
1639
- console.warn('DashEngine error:', typedData?.error?.message ?? typedData?.error ?? typedData);
1640
- });
1641
- }
1642
- }
1643
-
1644
- /**
1645
- * Poster HLS handler.
1646
- *
1647
- * Used to play a looping HLS stream as a video poster/background.
1648
- * The Hls constructor is passed at attach-time (not imported) because
1649
- * hls.js is loaded from CDN at runtime.
1650
- */
1651
- function createPosterHandler() {
1652
- let player = null;
1653
- return {
1654
- attach({ src, container, Hls }) {
1655
- console.log('Poster HLS: attach media', src, container);
1656
- player = new Hls();
1657
- player.loadSource(src);
1658
- player.attachMedia(container);
1659
- },
1660
- close() {
1661
- if (player) {
1662
- try {
1663
- player.detachMedia?.();
1664
- player.destroy?.();
1665
- }
1666
- catch (error) {
1667
- console.error('Poster HLS player close error:', error);
1668
- }
1669
- player = null;
1670
- }
1671
- },
1672
- play() {
1673
- player?.media?.play();
1674
- },
1675
- pause() {
1676
- player?.media?.pause();
1677
- }
1678
- };
1679
- }
1680
-
1681
- /**
1682
- * iOS HLS handler.
1683
- *
1684
- * Used when Hls.isSupported() returns false (native HLS on iOS Safari).
1685
- * Native video elements may not report correct duration for live streams,
1686
- * so this handler parses the HLS manifest directly to sum EXTINF durations.
1687
- *
1688
- * Fetches the master manifest, extracts the first child manifest URL,
1689
- * then polls every 5 seconds to keep duration up-to-date.
1690
- */
1691
- function createIosHandler(src) {
1692
- const data = {};
1693
- let intervalId;
1694
- async function init() {
1695
- const masterResponse = await fetch(src);
1696
- const masterManifest = await masterResponse.text();
1697
- const childLine = masterManifest
1698
- .split('\n')
1699
- .find((line) => /^[^#]+\.m3u8/i.test(line));
1700
- if (!childLine)
1701
- return;
1702
- const { href: childAddress } = new URL(childLine, src);
1703
- async function pollDuration() {
1704
- const childResponse = await fetch(childAddress);
1705
- const childManifest = await childResponse.text();
1706
- data.duration = childManifest
1707
- .split('\n')
1708
- .filter((line) => line.indexOf('#EXTINF:') !== -1)
1709
- .map((line) => Number(line.split(':')[1].replace(',', '')))
1710
- .reduce((accumulated, duration) => accumulated + duration, 0);
1711
- }
1712
- intervalId = setInterval(() => {
1713
- pollDuration().catch((error) => {
1714
- console.warn('iOS HLS: Failed to poll child manifest duration', error);
1715
- });
1716
- }, 5000);
1717
- }
1718
- init().catch((error) => {
1719
- console.warn('iOS HLS: Failed to initialize from master manifest', error);
1720
- });
1721
- return {
1722
- data,
1723
- destroy() {
1724
- if (intervalId !== undefined)
1725
- clearInterval(intervalId);
1726
- }
1727
- };
1728
- }
1729
-
1730
- /**
1731
- * Custom HLS ABR controller.
1732
- *
1733
- * Ported from component/engines/abr/hls.js (509 LOC).
1734
- *
1735
- * Creates a class that extends hls.js's built-in ABR controller and overrides
1736
- * nextAutoLevel with bandwidth-aware and buffer-level-based quality selection.
1737
- *
1738
- * Usage: pass the result as `abrController` in the Hls constructor config.
1739
- *
1740
- * Note: hls.js internals are accessed via type assertions because the library
1741
- * does not export its internal controller types (Open Question 3 in RESEARCH.md).
1742
- */
1743
- // ---------------------------------------------------------------------------
1744
- // Standard deviation helper
1745
- // ---------------------------------------------------------------------------
1746
- function standardDeviation(arr) {
1747
- const mean = arr.reduce((accumulator, value) => accumulator + value, 0) / arr.length;
1748
- const squaredDiffs = arr.map((value) => (value - mean) ** 2);
1749
- return Math.sqrt(squaredDiffs.reduce((accumulator, value) => accumulator + value, 0) / (arr.length - 1));
1750
- }
1751
- // ---------------------------------------------------------------------------
1752
- // Playlist level type constants (inlined to avoid hls.js internal import)
1753
- // ---------------------------------------------------------------------------
1754
- const PlaylistLevelType = {
1755
- MAIN: 'main'};
1756
- // ---------------------------------------------------------------------------
1757
- // Buffer helper (simplified version of hls.js's BufferHelper)
1758
- // ---------------------------------------------------------------------------
1759
- const BufferHelperImpl = {
1760
- bufferInfo(media, pos, maxHoleDuration) {
1761
- if (!media.buffered || media.buffered.length === 0) {
1762
- return { end: 0, start: 0, len: 0, nextStart: 0 };
1763
- }
1764
- for (let index = 0; index < media.buffered.length; index++) {
1765
- const start = media.buffered.start(index);
1766
- const end = media.buffered.end(index);
1767
- if (pos >= start - maxHoleDuration && pos <= end + maxHoleDuration) {
1768
- return { end, start, len: end - pos, nextStart: 0 };
1769
- }
1770
- }
1771
- return { end: 0, start: 0, len: 0, nextStart: 0 };
1772
- }
1773
- };
1774
- // ---------------------------------------------------------------------------
1775
- // createHlsAbrController
1776
- // ---------------------------------------------------------------------------
1777
- /**
1778
- * Factory that creates a custom ABR controller class extending hls.js's built-in
1779
- * ABR controller. Takes the Hls constructor so it can access Events and DefaultConfig.
1780
- *
1781
- * The returned class can be passed as `abrController` in the Hls constructor config.
1782
- */
1783
- function createHlsAbrController(HlsConstructor) {
1784
- const BaseAbrController = HlsConstructor.DefaultConfig.abrController;
1785
- // If no base controller is available, return a no-op fallback
1786
- if (!BaseAbrController) {
1787
- return class FallbackAbrController {
1788
- constructor(_hlsInstance) { }
1789
- get nextAutoLevel() { return -1; }
1790
- set nextAutoLevel(_value) { }
1791
- destroy() { }
1792
- };
1793
- }
1794
- const { Events } = HlsConstructor;
1795
- class CustomAbrController extends BaseAbrController {
1796
- constructor(hlsInstance) {
1797
- super(hlsInstance, Events.FRAG_LOADING, Events.FRAG_LOADED, Events.FRAG_BUFFERED, Events.ERROR);
1798
- this.hls = hlsInstance;
1799
- this.lastLoadedLevel = 1;
1800
- this._nextAutoLevel = -1;
1801
- this.onCheck = this._abandonRulesCheck.bind(this);
1802
- this.onBufferCheckTick = this._onBufferCheckTick.bind(this);
1803
- this.bufferCheckLastPlayPos = 0;
1804
- this.bufferCheckCurrentPlayPos = 0;
1805
- this.bufferCheckBufferingDetected = false;
1806
- this.lastDuration = 6;
1807
- this.setBufferValues = new Set();
1808
- this.setBitrate = new Set();
1809
- this.loading = false;
1810
- this.playingTime = 0;
1811
- this.minBufferValue = 30;
1812
- this.maxBufferValue = 0;
1813
- this.lastBufferValue = 30;
1814
- this.maxBufferLength = 30;
1815
- this.rebuffering = 0;
1816
- this.rebufferingRatio = 0.15;
1817
- this.pastBufferFactor = 5;
1818
- this.bitrateTestDelay = 0;
1819
- this.timer = undefined;
1820
- this.bufferCheckTimer = undefined;
1821
- this.init = true;
1822
- this.fraglastBuffered = 0;
1823
- this.lastLoadedFragLevel = 1;
1824
- this.fragCurrent = null;
1825
- this.partCurrent = null;
1826
- this.bufferCheckVideo = null;
1827
- this.registerCustomListeners();
1828
- }
1829
- destroy() {
1830
- this.unregisterCustomListeners();
1831
- this.clearCustomTimer();
1832
- if (this.bufferCheckTimer !== undefined) {
1833
- clearInterval(this.bufferCheckTimer);
1834
- this.bufferCheckTimer = undefined;
1835
- }
1836
- if (typeof super.destroy === 'function') {
1837
- super.destroy();
1838
- }
1839
- }
1840
- registerCustomListeners() {
1841
- this.hls.on(Events.FRAG_LOADING, this.onFragLoading, this);
1842
- this.hls.on(Events.FRAG_LOADED, this.onFragLoaded, this);
1843
- this.hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
1844
- this.hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this);
1845
- this.hls.on(Events.ERROR, this.onError, this);
1846
- }
1847
- unregisterCustomListeners() {
1848
- this.hls.off(Events.FRAG_LOADING, this.onFragLoading, this);
1849
- this.hls.off(Events.FRAG_LOADED, this.onFragLoaded, this);
1850
- this.hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
1851
- this.hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this);
1852
- this.hls.off(Events.ERROR, this.onError, this);
1853
- }
1854
- clearCustomTimer() {
1855
- if (this.timer !== undefined) {
1856
- clearInterval(this.timer);
1857
- this.timer = undefined;
1858
- }
1859
- }
1860
- onFragLoading(_event, data) {
1861
- const fragData = data;
1862
- const { frag } = fragData;
1863
- this.loading = true;
1864
- if (frag.type === PlaylistLevelType.MAIN) {
1865
- if (!this.timer) {
1866
- this.fragCurrent = frag;
1867
- this.partCurrent = fragData.part ?? null;
1868
- this.timer = setInterval(this.onCheck, 100);
1869
- }
1870
- }
1871
- }
1872
- getBuffer() {
1873
- if (!this.hls.media)
1874
- return 0;
1875
- const playbackRate = this.hls.media.playbackRate || 1;
1876
- const pos = this.hls.media.currentTime;
1877
- const bufferInfo = BufferHelperImpl.bufferInfo(this.hls.media, pos, this.maxBufferLength);
1878
- return (bufferInfo.end - pos) / playbackRate;
1879
- }
1880
- _abandonRulesCheck() {
1881
- const { hls } = this;
1882
- const video = hls.media;
1883
- const frag = this.fragCurrent;
1884
- const part = this.partCurrent;
1885
- if (!frag || !video)
1886
- return;
1887
- const stats = part ? part.stats : frag.stats;
1888
- if (stats.aborted) {
1889
- this.clearCustomTimer();
1890
- this._nextAutoLevel = -1;
1891
- return;
1892
- }
1893
- if (!hls.autoLevelEnabled || video.paused || !video.playbackRate || !video.readyState)
1894
- return;
1895
- if (frag.type !== PlaylistLevelType.MAIN || frag.sn === 'initSegment')
1896
- return;
1897
- this.bufferCheckLastPlayPos = 0;
1898
- this.bufferCheckCurrentPlayPos = 0;
1899
- this.bufferCheckBufferingDetected = false;
1900
- this.bufferCheckVideo = video;
1901
- this.bufferCheckTimer = setInterval(this.onBufferCheckTick, 1000);
1902
- }
1903
- _onBufferCheckTick() {
1904
- if (!this.hls)
1905
- return;
1906
- if (this.loading === false) {
1907
- const bufferLength = this.getBuffer();
1908
- this.getBufferMinMaxValue(bufferLength);
1909
- }
1910
- const video = this.bufferCheckVideo;
1911
- if (!video)
1912
- return;
1913
- this.playingTime += 1;
1914
- this.bufferCheckCurrentPlayPos = video.currentTime;
1915
- if (this.bufferCheckLastPlayPos > 0 && video.paused && !this.bufferCheckBufferingDetected && this.bufferCheckCurrentPlayPos <= this.bufferCheckLastPlayPos) {
1916
- this.rebuffering += 1;
1917
- this.bufferCheckBufferingDetected = true;
1918
- if (this.hls.currentLevel > 0) {
1919
- this.reset();
1920
- if (this.lastLoadedFragLevel !== 0) {
1921
- this.clearCustomTimer();
1922
- }
1923
- }
1924
- }
1925
- if (this.bufferCheckLastPlayPos > 0 && this.bufferCheckBufferingDetected && !video.paused && this.bufferCheckCurrentPlayPos > this.bufferCheckLastPlayPos) {
1926
- this.bufferCheckBufferingDetected = false;
1927
- }
1928
- this.bufferCheckLastPlayPos = this.bufferCheckCurrentPlayPos;
1929
- }
1930
- onFragLoaded(_event, data) {
1931
- const fragData = data;
1932
- const { frag, part } = fragData;
1933
- const stats = part ? part.stats : frag.stats;
1934
- const duration = part ? part.duration : frag.duration;
1935
- this.loading = false;
1936
- if (frag.type === PlaylistLevelType.MAIN && typeof frag.sn === 'number') {
1937
- this.clearCustomTimer();
1938
- this.lastLoadedFragLevel = frag.level;
1939
- this._nextAutoLevel = -1;
1940
- const level = this.hls.levels[frag.level];
1941
- if (level) {
1942
- const loadedBytes = (level.loaded ? level.loaded.bytes : 0) + stats.loaded;
1943
- const loadedDuration = (level.loaded ? level.loaded.duration : 0) + duration;
1944
- level.loaded = { bytes: loadedBytes, duration: loadedDuration };
1945
- level.realBitrate = Math.round((8 * loadedBytes) / loadedDuration);
1946
- this.setBitrate.add(level.realBitrate);
1947
- this.lastDuration = duration;
1948
- this.maxBufferLength = this.lastDuration * 10;
1949
- }
1950
- // Trigger onFragBuffered inline
1951
- this.onFragBuffered(Events.FRAG_BUFFERED, { stats, frag, part, id: frag.type });
1952
- }
1953
- }
1954
- getBufferMinMaxValue(value) {
1955
- if (this.lastBufferValue < value) {
1956
- this.setBufferValues.add(this.minBufferValue);
1957
- this.maxBufferValue = value;
1958
- this.lastBufferValue = value;
1959
- this.minBufferValue = value;
1960
- }
1961
- if (value > this.maxBufferValue) {
1962
- this.maxBufferValue = value;
1963
- }
1964
- if (value < this.minBufferValue) {
1965
- this.minBufferValue = value;
1966
- this.lastBufferValue = this.minBufferValue;
1967
- }
1968
- }
1969
- reset() {
1970
- this.setBufferValues.clear();
1971
- this.setBitrate.clear();
1972
- }
1973
- onError(_event, data) {
1974
- const errorData = data;
1975
- switch (errorData.details) {
1976
- case 'fragLoadTimeOut':
1977
- this.clearCustomTimer();
1978
- break;
1979
- }
1980
- }
1981
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1982
- onLevelLoaded(_event, _data) {
1983
- // No-op: level details updated via getBuffer on demand
1984
- }
1985
- onFragBuffered(_event, data) {
1986
- const fragData = data;
1987
- const { frag, part } = fragData;
1988
- const stats = part ? part.stats : (fragData.stats ?? frag.stats);
1989
- if (stats.aborted)
1990
- return;
1991
- if (frag.type !== PlaylistLevelType.MAIN || frag.sn === 'initSegment')
1992
- return;
1993
- const loadingStart = stats.loading?.start ?? 0;
1994
- const parsingEnd = stats.parsing?.end ?? 0;
1995
- const fragLoadingProcessingMs = parsingEnd - loadingStart;
1996
- this.bitrateTestDelay = fragLoadingProcessingMs / 1000;
1997
- this.fraglastBuffered = Date.now();
1998
- }
1999
- findNextBestLevel(sd) {
2000
- const levels = this.hls.levels;
2001
- for (let index = 0; index < levels.length; index++) {
2002
- if (index < levels.length - 2
2003
- && (this.avgBitrate * 0.8) >= levels[index].bitrate
2004
- && this.avgBitrate <= levels[index + 1].bitrate) {
2005
- this.reset();
2006
- this.lastLoadedFragLevel = (sd < 3) ? index : index - 1;
2007
- return this.lastLoadedFragLevel;
2008
- }
2009
- if (index === levels.length - 1) {
2010
- const buffer = this.getBuffer();
2011
- if (buffer > this.lastDuration + (this.lastDuration / 2) && sd < 3) {
2012
- this.lastLoadedFragLevel = index;
2013
- }
2014
- else {
2015
- this.lastLoadedFragLevel = index - 1;
2016
- }
2017
- this.reset();
2018
- return this.lastLoadedFragLevel;
2019
- }
2020
- }
2021
- return this.lastLoadedFragLevel;
2022
- }
2023
- get avgBitrate() {
2024
- if (this.setBitrate.size === 0)
2025
- return 0;
2026
- return Array.from(this.setBitrate).reduce((acc, val) => acc + val, 0) / this.setBitrate.size;
2027
- }
2028
- /* istanbul ignore next */
2029
- set avgBitrate(_value) {
2030
- // Computed from setBitrate; setter is a no-op; required by TypeScript accessor rules
2031
- }
2032
- findBestLevel() {
2033
- if (!this.hls)
2034
- return 0;
2035
- const maxLevel = this.hls.levels.length - 1;
2036
- const minLevel = 0;
2037
- const bufferLength = this.getBuffer();
2038
- // Low buffer: drop quality
2039
- if (bufferLength < (this.hls.config.maxBufferLength * 0.10)) {
2040
- this.reset();
2041
- this.lastLoadedFragLevel = Math.max(this.lastLoadedFragLevel - 1, 0);
2042
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2043
- return this.lastLoadedFragLevel;
2044
- }
2045
- // Note: the fraglastBuffered stale-buffer check was removed here.
2046
- // The original code contained an inner buffer re-check that was structurally
2047
- // unreachable (duplicate check mirrors the outer low-buffer check at line 480).
2048
- // Init: start at highest quality
2049
- if (this.init === true) {
2050
- this.init = false;
2051
- this.lastLoadedFragLevel = maxLevel;
2052
- return this.lastLoadedFragLevel;
2053
- }
2054
- // Good buffer: select quality based on bitrate (only called when setBitrate is non-empty)
2055
- if (bufferLength > this.hls.config.maxBufferLength * 0.30) {
2056
- this.lastLoadedFragLevel = this.findNextBestLevel(5);
2057
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2058
- return this.lastLoadedFragLevel;
2059
- }
2060
- // Historical buffer stats available
2061
- if (this.setBufferValues.size >= this.pastBufferFactor) {
2062
- try {
2063
- const avgBuffer = Array.from(this.setBufferValues).reduce((acc, val) => acc + val, 0) / this.setBufferValues.size;
2064
- const sdBuffer = standardDeviation(Array.from(this.setBufferValues));
2065
- this.setBufferValues.clear();
2066
- const currentBitrate = this.hls.levels[this.hls.currentLevel]?.bitrate ?? 0;
2067
- if (avgBuffer <= this.hls.config.maxBufferLength * 0.1 && currentBitrate >= this.hls.levels[0].bitrate) {
2068
- this.reset();
2069
- this.lastLoadedFragLevel = Math.max(this.lastLoadedFragLevel - 1, 0);
2070
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2071
- return this.lastLoadedFragLevel;
2072
- }
2073
- if (avgBuffer <= this.hls.config.maxBufferLength * 0.3) {
2074
- this.reset();
2075
- return this.lastLoadedFragLevel;
2076
- }
2077
- if (avgBuffer <= this.hls.config.maxBufferLength * 0.7 && currentBitrate < this.avgBitrate) {
2078
- this.reset();
2079
- this.lastLoadedFragLevel = this.findNextBestLevel(sdBuffer);
2080
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2081
- return this.lastLoadedFragLevel;
2082
- }
2083
- if (avgBuffer > this.hls.config.maxBufferLength * 0.7 && currentBitrate < this.avgBitrate * 0.8) {
2084
- this.lastLoadedFragLevel = this.findNextBestLevel(sdBuffer);
2085
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2086
- return this.lastLoadedFragLevel;
2087
- }
2088
- this.reset();
2089
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2090
- return this.lastLoadedFragLevel;
2091
- }
2092
- catch (error) {
2093
- console.warn('HLS ABR: findBestLevel error', error);
2094
- this.lastLoadedFragLevel = this.findNextBestLevel(3);
2095
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2096
- return this.lastLoadedFragLevel;
2097
- }
2098
- }
2099
- this.lastLoadedFragLevel = Math.min(Math.max(this.lastLoadedFragLevel, minLevel), maxLevel);
2100
- return this.lastLoadedFragLevel;
2101
- }
2102
- get _nextABRAutoLevel() {
2103
- try {
2104
- if (this.playingTime > 0 && (this.rebuffering / this.playingTime) > this.rebufferingRatio) {
2105
- this.lastLoadedFragLevel = 0;
2106
- return 0;
2107
- }
2108
- if (this.setBitrate.size > 0) {
2109
- this.lastLoadedFragLevel = this.findBestLevel();
2110
- }
2111
- return this.lastLoadedFragLevel;
2112
- }
2113
- catch (error) {
2114
- console.warn('HLS ABR: Could not define level, using last loaded level', error);
2115
- return this.lastLoadedFragLevel;
2116
- }
2117
- }
2118
- get nextAutoLevel() {
2119
- return this._nextABRAutoLevel;
2120
- }
2121
- set nextAutoLevel(nextLevel) {
2122
- this._nextAutoLevel = nextLevel;
2123
- }
2124
- }
2125
- return CustomAbrController;
2126
- }
2127
-
2128
- var hls = /*#__PURE__*/Object.freeze({
2129
- __proto__: null,
2130
- createHlsAbrController: createHlsAbrController
2131
- });
2132
-
2133
- /**
2134
- * HLS Snapshot Handler.
2135
- *
2136
- * Ported from component/engines/snapshot/hls.js (102 LOC).
2137
- *
2138
- * Creates a separate Hls instance for seekbar thumbnail preview rendering.
2139
- * The snapshot instance runs independently from the main playback engine with
2140
- * its own off-screen video element and low-buffer settings.
2141
- *
2142
- * Token injection (Pitfall 6): tokenManager is captured via closure,
2143
- * not stored on `this`, to avoid binding issues inside pLoader.
2144
- */
2145
- // ---------------------------------------------------------------------------
2146
- // HlsSnapshotHandler
2147
- // ---------------------------------------------------------------------------
2148
- class HlsSnapshotHandler {
2149
- constructor(config, tokenManager) {
2150
- this.driver = null;
2151
- this.offscreenVideo = null;
2152
- this.throttleTimeout = null;
2153
- this.pendingSeekTime = 0;
2154
- this.config = config;
2155
- this.tokenManager = tokenManager;
2156
- }
2157
- /**
2158
- * Initialize the snapshot Hls instance using the CDN-loaded Hls constructor.
2159
- * Creates a separate Hls instance with minimal buffering for thumbnail generation.
2160
- */
2161
- init(HlsConstructor) {
2162
- // Create an off-screen video element for the snapshot player
2163
- const offscreenVideo = document.createElement('video');
2164
- offscreenVideo.preload = 'none';
2165
- this.offscreenVideo = offscreenVideo;
2166
- // Capture tokenManager via closure (Pitfall 6)
2167
- const tokenManager = this.tokenManager;
2168
- // Build pLoader class with token injection for manifest requests
2169
- let PLoader;
2170
- if (tokenManager) {
2171
- class SnapshotPLoader extends HlsConstructor.DefaultConfig.loader {
2172
- constructor(loaderConfig) {
2173
- super(loaderConfig);
2174
- const originalLoad = this.load.bind(this);
2175
- this.load = async function (context, loadConfig, callbacks) {
2176
- if (!context.url) {
2177
- originalLoad(context, loadConfig, callbacks);
2178
- return;
2179
- }
2180
- // Only handle .m3u8 manifest URLs
2181
- const parsedUrl = new URL(context.url);
2182
- const lastSegment = parsedUrl.pathname.split('/').pop();
2183
- if (!lastSegment.endsWith('.m3u8')) {
2184
- originalLoad(context, loadConfig, callbacks);
2185
- return;
2186
- }
2187
- // Refresh token if expired (tokenManager is non-null within this if block)
2188
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2189
- context.url = await tokenManager.updateUrlWithTokenParams({ url: context.url });
2190
- originalLoad(context, loadConfig, callbacks);
2191
- };
2192
- }
2193
- }
2194
- PLoader = SnapshotPLoader;
2195
- }
2196
- const driverConfig = {
2197
- startLevel: 0,
2198
- enableWebVTT: false,
2199
- maxBufferLength: 1,
2200
- ...(this.config.engineSettings ?? {}),
2201
- ...(PLoader ? { pLoader: PLoader } : {})
2202
- };
2203
- const driver = new HlsConstructor(driverConfig);
2204
- this.driver = driver;
2205
- // Apply discontinuity workaround before attaching
2206
- applyDiscontinuityWorkaround(driver, HlsConstructor.Events);
2207
- driver.attachMedia(offscreenVideo);
2208
- if (this.config.src) {
2209
- driver.loadSource(this.config.src);
2210
- }
2211
- }
2212
- /**
2213
- * Seek the snapshot player to the specified time.
2214
- * Throttled to avoid excessive seeks (250ms minimum interval).
2215
- */
2216
- take(time) {
2217
- if (!this.offscreenVideo)
2218
- return;
2219
- if (this.throttleTimeout !== null) {
2220
- clearTimeout(this.throttleTimeout);
2221
- }
2222
- this.pendingSeekTime = time;
2223
- this.throttleTimeout = setTimeout(this.applyPendingSeek.bind(this), 250);
2224
- }
2225
- applyPendingSeek() {
2226
- if (this.offscreenVideo) {
2227
- this.offscreenVideo.currentTime = this.pendingSeekTime;
2228
- }
2229
- this.throttleTimeout = null;
2230
- }
2231
- /**
2232
- * Destroy the snapshot Hls instance and clean up resources.
2233
- */
2234
- destroy() {
2235
- if (this.throttleTimeout !== null) {
2236
- clearTimeout(this.throttleTimeout);
2237
- this.throttleTimeout = null;
2238
- }
2239
- if (this.driver !== null) {
2240
- try {
2241
- this.driver.detachMedia();
2242
- this.driver.destroy();
2243
- }
2244
- catch (error) {
2245
- console.warn('HlsSnapshotHandler: error during destroy', error);
2246
- }
2247
- this.driver = null;
2248
- }
2249
- this.offscreenVideo = null;
2250
- }
2251
- }
2252
-
2253
- /**
2254
- * DASH Snapshot Handler
2255
- *
2256
- * Ported from component/engines/snapshot/dash.js
2257
- *
2258
- * Creates a separate dash.js MediaPlayer instance for seekbar preview thumbnails.
2259
- * The snapshot player runs in an off-screen video element and is independent
2260
- * from the main playback player.
2261
- */
2262
- // ---------------------------------------------------------------------------
2263
- // DashSnapshotHandler
2264
- // ---------------------------------------------------------------------------
2265
- class DashSnapshotHandler {
2266
- constructor(src) {
2267
- this.player = null;
2268
- this.offscreenVideo = null;
2269
- this.ready = false;
2270
- this.src = src;
2271
- }
2272
- /**
2273
- * Initialise the snapshot player using the provided dashjs constructor.
2274
- * Resolves with a SnapshotHandle once STREAM_INITIALIZED fires.
2275
- * If initialisation fails, resolves with a no-op handle.
2276
- */
2277
- init(dashjs) {
2278
- const noopHandle = { take: () => undefined, destroy: () => undefined };
2279
- return new Promise((resolve) => {
2280
- const video = document.createElement('video');
2281
- video.style.display = 'none';
2282
- document.body.appendChild(video);
2283
- this.offscreenVideo = video;
2284
- let player;
2285
- try {
2286
- player = dashjs.MediaPlayer().create();
2287
- this.player = player;
2288
- player.initialize(video, this.src, false);
2289
- player.updateSettings({
2290
- streaming: {
2291
- text: { defaultEnabled: false },
2292
- buffer: { fastSwitchEnabled: false },
2293
- abr: {
2294
- autoSwitchBitrate: { video: false, audio: false },
2295
- initialBitrate: { video: 0, audio: 0 }
2296
- }
2297
- }
2298
- });
2299
- }
2300
- catch (error) {
2301
- const message = error instanceof Error ? error.message : String(error);
2302
- console.warn('Snapshot handler initialization failed:', message);
2303
- this.cleanup();
2304
- resolve(noopHandle);
2305
- return;
2306
- }
2307
- // Handle errors gracefully
2308
- player.on(dashjs.MediaPlayer.events['ERROR'], (eventData) => {
2309
- const typedData = eventData;
2310
- console.warn('Snapshot dash.js error:', typedData?.error?.message ?? typedData?.error ?? typedData);
2311
- });
2312
- const onStreamInitialized = () => {
2313
- player.setMute(true);
2314
- player.off(dashjs.MediaPlayer.events['STREAM_INITIALIZED'], onStreamInitialized);
2315
- this.ready = true;
2316
- let throttleTimer = null;
2317
- const handle = {
2318
- take: (time) => {
2319
- if (!this.ready)
2320
- return;
2321
- if (throttleTimer !== null)
2322
- return;
2323
- throttleTimer = setTimeout(() => {
2324
- throttleTimer = null;
2325
- }, 250);
2326
- try {
2327
- player.seek(time);
2328
- if (player.isPaused())
2329
- player.play();
2330
- }
2331
- catch (error) {
2332
- const message = error instanceof Error ? error.message : String(error);
2333
- console.warn('Snapshot seek error:', message);
2334
- }
2335
- },
2336
- destroy: () => {
2337
- this.ready = false;
2338
- this.destroy();
2339
- }
2340
- };
2341
- resolve(handle);
2342
- };
2343
- player.on(dashjs.MediaPlayer.events['STREAM_INITIALIZED'], onStreamInitialized);
2344
- // Pause immediately when playback starts — we only seek, never play
2345
- player.on(dashjs.MediaPlayer.events['PLAYBACK_PLAYING'], () => {
2346
- if (!player.isPaused())
2347
- player.pause();
2348
- });
2349
- });
2350
- }
2351
- /**
2352
- * Reset and destroy the snapshot player and remove the off-screen video element.
2353
- */
2354
- destroy() {
2355
- this.ready = false;
2356
- if (this.player !== null) {
2357
- try {
2358
- this.player.reset();
2359
- }
2360
- catch {
2361
- // Ignore reset errors
2362
- }
2363
- this.player = null;
2364
- }
2365
- this.cleanup();
2366
- }
2367
- cleanup() {
2368
- if (this.offscreenVideo !== null) {
2369
- this.offscreenVideo.remove();
2370
- this.offscreenVideo = null;
2371
- }
2372
- }
2373
- }
2374
-
2375
- exports.BaseEngine = BaseEngine;
2376
- exports.CDNTokenManager = CDNTokenManager;
2377
- exports.DashEngine = DashEngine;
2378
- exports.DashSnapshotHandler = DashSnapshotHandler;
2379
- exports.DrmConfigurator = DrmConfigurator;
2380
- exports.HlsEngine = HlsEngine;
2381
- exports.HlsSnapshotHandler = HlsSnapshotHandler;
2382
- exports.StallWatchdog = StallWatchdog;
2383
- exports.applyDashRetrySettings = applyDashRetrySettings;
2384
- exports.applyDiscontinuityWorkaround = applyDiscontinuityWorkaround;
2385
- exports.createDashAbrRule = createDashAbrRule;
2386
- exports.createHlsAbrController = createHlsAbrController;
2387
- exports.createIosHandler = createIosHandler;
2388
- exports.createPosterHandler = createPosterHandler;
2389
- exports.handleHlsRetry = handleHlsRetry;
2390
- exports.loadScript = loadScript;
2391
-
2392
- }));
2393
- //# sourceMappingURL=ebplayer-engines.bundle.js.map