eb-player 2.0.12 → 2.0.13
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
|