@scarlett-player/hls 1.1.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -11
- package/dist/chunk-IFUZZQQE.js +1141 -0
- package/dist/index.cjs +202 -53
- package/dist/index.d.cts +6 -8
- package/dist/index.d.ts +6 -8
- package/dist/index.js +34 -691
- package/dist/light.cjs +431 -43
- package/dist/light.d.cts +11 -4
- package/dist/light.d.ts +11 -4
- package/dist/light.js +34 -461
- package/dist/{types-DOzoPHQe.d.cts → types-D-HW4lmM.d.cts} +8 -0
- package/dist/{types-DOzoPHQe.d.ts → types-D-HW4lmM.d.ts} +8 -0
- package/package.json +2 -2
- package/dist/chunk-L73PRXS4.js +0 -337
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/create-hls-plugin.ts
|
|
8
|
+
import { ErrorCode } from "@scarlett-player/core";
|
|
9
|
+
|
|
10
|
+
// src/quality.ts
|
|
11
|
+
function formatLevel(level) {
|
|
12
|
+
if (level.name) {
|
|
13
|
+
return level.name;
|
|
14
|
+
}
|
|
15
|
+
if (level.height) {
|
|
16
|
+
const standardLabels = {
|
|
17
|
+
2160: "4K",
|
|
18
|
+
1440: "1440p",
|
|
19
|
+
1080: "1080p",
|
|
20
|
+
720: "720p",
|
|
21
|
+
480: "480p",
|
|
22
|
+
360: "360p",
|
|
23
|
+
240: "240p",
|
|
24
|
+
144: "144p"
|
|
25
|
+
};
|
|
26
|
+
const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
|
|
27
|
+
if (Math.abs(closest - level.height) <= 20) {
|
|
28
|
+
return standardLabels[closest];
|
|
29
|
+
}
|
|
30
|
+
return `${level.height}p`;
|
|
31
|
+
}
|
|
32
|
+
if (level.bitrate) {
|
|
33
|
+
return formatBitrate(level.bitrate);
|
|
34
|
+
}
|
|
35
|
+
return "Unknown";
|
|
36
|
+
}
|
|
37
|
+
function formatBitrate(bitrate) {
|
|
38
|
+
if (bitrate >= 1e6) {
|
|
39
|
+
return `${(bitrate / 1e6).toFixed(1)} Mbps`;
|
|
40
|
+
}
|
|
41
|
+
if (bitrate >= 1e3) {
|
|
42
|
+
return `${Math.round(bitrate / 1e3)} Kbps`;
|
|
43
|
+
}
|
|
44
|
+
return `${bitrate} bps`;
|
|
45
|
+
}
|
|
46
|
+
function mapLevels(levels, _currentLevel) {
|
|
47
|
+
return levels.map((level, index) => ({
|
|
48
|
+
index,
|
|
49
|
+
width: level.width || 0,
|
|
50
|
+
height: level.height || 0,
|
|
51
|
+
bitrate: level.bitrate || 0,
|
|
52
|
+
label: formatLevel(level),
|
|
53
|
+
codec: level.codecSet
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
function getInitialBandwidthEstimate(overrideBps) {
|
|
57
|
+
const HLS_DEFAULT_ESTIMATE = 5e5;
|
|
58
|
+
if (overrideBps !== void 0 && overrideBps > 0) {
|
|
59
|
+
return overrideBps;
|
|
60
|
+
}
|
|
61
|
+
const connection = navigator.connection;
|
|
62
|
+
if (connection?.downlink && connection.downlink > 0) {
|
|
63
|
+
const bps = connection.downlink * 1e6;
|
|
64
|
+
return Math.round(bps * 0.85);
|
|
65
|
+
}
|
|
66
|
+
return HLS_DEFAULT_ESTIMATE;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/event-map.ts
|
|
70
|
+
var HLS_ERROR_TYPES = {
|
|
71
|
+
NETWORK_ERROR: "networkError",
|
|
72
|
+
MEDIA_ERROR: "mediaError",
|
|
73
|
+
KEY_SYSTEM_ERROR: "keySystemError",
|
|
74
|
+
MUX_ERROR: "muxError",
|
|
75
|
+
OTHER_ERROR: "otherError"
|
|
76
|
+
};
|
|
77
|
+
function mapErrorType(hlsType) {
|
|
78
|
+
switch (hlsType) {
|
|
79
|
+
case HLS_ERROR_TYPES.NETWORK_ERROR:
|
|
80
|
+
return "network";
|
|
81
|
+
case HLS_ERROR_TYPES.MEDIA_ERROR:
|
|
82
|
+
return "media";
|
|
83
|
+
case HLS_ERROR_TYPES.MUX_ERROR:
|
|
84
|
+
return "mux";
|
|
85
|
+
default:
|
|
86
|
+
return "other";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function parseHlsError(data) {
|
|
90
|
+
return {
|
|
91
|
+
type: mapErrorType(data.type),
|
|
92
|
+
details: data.details || "Unknown error",
|
|
93
|
+
fatal: data.fatal || false,
|
|
94
|
+
url: data.url,
|
|
95
|
+
reason: data.reason,
|
|
96
|
+
response: data.response
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function setupHlsEventHandlers(hls, api, callbacks) {
|
|
100
|
+
const handlers = [];
|
|
101
|
+
const addHandler = (event, handler) => {
|
|
102
|
+
hls.on(event, handler);
|
|
103
|
+
handlers.push({ event, handler });
|
|
104
|
+
};
|
|
105
|
+
addHandler("hlsManifestParsed", (_event, data) => {
|
|
106
|
+
api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
|
|
107
|
+
const levels = data.levels.map((level, index) => ({
|
|
108
|
+
id: `level-${index}`,
|
|
109
|
+
label: formatLevel(level),
|
|
110
|
+
width: level.width,
|
|
111
|
+
height: level.height,
|
|
112
|
+
bitrate: level.bitrate,
|
|
113
|
+
active: index === hls.currentLevel
|
|
114
|
+
}));
|
|
115
|
+
api.setState("qualities", levels);
|
|
116
|
+
api.emit("quality:levels", {
|
|
117
|
+
levels: levels.map((l) => ({ id: l.id, label: l.label }))
|
|
118
|
+
});
|
|
119
|
+
callbacks.onManifestParsed?.(data.levels);
|
|
120
|
+
});
|
|
121
|
+
addHandler("hlsLevelSwitched", (_event, data) => {
|
|
122
|
+
const level = hls.levels[data.level];
|
|
123
|
+
const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
|
|
124
|
+
api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
|
|
125
|
+
if (level) {
|
|
126
|
+
const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
|
|
127
|
+
api.setState("currentQuality", {
|
|
128
|
+
id: isAuto ? "auto" : `level-${data.level}`,
|
|
129
|
+
label,
|
|
130
|
+
width: level.width,
|
|
131
|
+
height: level.height,
|
|
132
|
+
bitrate: level.bitrate,
|
|
133
|
+
active: true
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
api.emit("quality:change", {
|
|
137
|
+
quality: level ? formatLevel(level) : "auto",
|
|
138
|
+
auto: isAuto
|
|
139
|
+
});
|
|
140
|
+
callbacks.onLevelSwitched?.(data.level);
|
|
141
|
+
});
|
|
142
|
+
let lastBandwidthUpdate = 0;
|
|
143
|
+
addHandler("hlsFragLoaded", () => {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
|
|
146
|
+
lastBandwidthUpdate = now;
|
|
147
|
+
api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
|
|
148
|
+
}
|
|
149
|
+
callbacks.onFragLoaded?.();
|
|
150
|
+
});
|
|
151
|
+
addHandler("hlsFragBuffered", () => {
|
|
152
|
+
api.setState("buffering", false);
|
|
153
|
+
callbacks.onBufferUpdate?.();
|
|
154
|
+
});
|
|
155
|
+
addHandler("hlsFragLoading", () => {
|
|
156
|
+
api.setState("buffering", true);
|
|
157
|
+
});
|
|
158
|
+
addHandler("hlsLevelLoaded", (_event, data) => {
|
|
159
|
+
if (data.details?.live !== void 0) {
|
|
160
|
+
api.setState("live", data.details.live);
|
|
161
|
+
if (data.details.live) {
|
|
162
|
+
const video = hls.media;
|
|
163
|
+
if (video && video.seekable && video.seekable.length > 0) {
|
|
164
|
+
const start = video.seekable.start(0);
|
|
165
|
+
const end = video.seekable.end(video.seekable.length - 1);
|
|
166
|
+
api.setState("seekableRange", { start, end });
|
|
167
|
+
const threshold = (data.details.targetduration ?? 3) * 3;
|
|
168
|
+
const isAtLiveEdge = end - video.currentTime < threshold;
|
|
169
|
+
api.setState("liveEdge", isAtLiveEdge);
|
|
170
|
+
const latency = end - video.currentTime;
|
|
171
|
+
api.setState("liveLatency", Math.max(0, latency));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
callbacks.onLiveUpdate?.();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
addHandler("hlsError", (_event, data) => {
|
|
178
|
+
const error = parseHlsError(data);
|
|
179
|
+
const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
|
|
180
|
+
if (isBufferHoleSeek) {
|
|
181
|
+
api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
|
|
182
|
+
details: error.details,
|
|
183
|
+
reason: error.reason
|
|
184
|
+
});
|
|
185
|
+
} else if (error.fatal) {
|
|
186
|
+
api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
|
|
187
|
+
type: error.type,
|
|
188
|
+
details: error.details,
|
|
189
|
+
url: error.url
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
|
|
193
|
+
type: error.type,
|
|
194
|
+
details: error.details,
|
|
195
|
+
fatal: error.fatal,
|
|
196
|
+
url: error.url
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
callbacks.onError?.(error);
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
for (const { event, handler } of handlers) {
|
|
203
|
+
hls.off(event, handler);
|
|
204
|
+
}
|
|
205
|
+
handlers.length = 0;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function setupVideoEventHandlers(video, api) {
|
|
209
|
+
const handlers = [];
|
|
210
|
+
const addHandler = (event, handler) => {
|
|
211
|
+
video.addEventListener(event, handler);
|
|
212
|
+
handlers.push({ event, handler });
|
|
213
|
+
};
|
|
214
|
+
addHandler("play", () => {
|
|
215
|
+
api.setState("paused", false);
|
|
216
|
+
});
|
|
217
|
+
addHandler("playing", () => {
|
|
218
|
+
api.setState("playing", true);
|
|
219
|
+
api.setState("paused", false);
|
|
220
|
+
api.setState("waiting", false);
|
|
221
|
+
api.setState("buffering", false);
|
|
222
|
+
api.setState("playbackState", "playing");
|
|
223
|
+
});
|
|
224
|
+
addHandler("pause", () => {
|
|
225
|
+
api.setState("playing", false);
|
|
226
|
+
api.setState("paused", true);
|
|
227
|
+
api.setState("playbackState", "paused");
|
|
228
|
+
});
|
|
229
|
+
addHandler("ended", () => {
|
|
230
|
+
api.setState("playing", false);
|
|
231
|
+
api.setState("ended", true);
|
|
232
|
+
api.setState("playbackState", "ended");
|
|
233
|
+
api.emit("playback:ended", void 0);
|
|
234
|
+
});
|
|
235
|
+
addHandler("timeupdate", () => {
|
|
236
|
+
api.setState("currentTime", video.currentTime);
|
|
237
|
+
api.emit("playback:timeupdate", { currentTime: video.currentTime });
|
|
238
|
+
const isLive = api.getState("live");
|
|
239
|
+
if (isLive && video.seekable && video.seekable.length > 0) {
|
|
240
|
+
const start = video.seekable.start(0);
|
|
241
|
+
const end = video.seekable.end(video.seekable.length - 1);
|
|
242
|
+
api.setState("seekableRange", { start, end });
|
|
243
|
+
const isAtLiveEdge = end - video.currentTime < 10;
|
|
244
|
+
api.setState("liveEdge", isAtLiveEdge);
|
|
245
|
+
api.setState("liveLatency", Math.max(0, end - video.currentTime));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
addHandler("durationchange", () => {
|
|
249
|
+
api.setState("duration", video.duration || 0);
|
|
250
|
+
api.emit("media:loadedmetadata", { duration: video.duration || 0 });
|
|
251
|
+
});
|
|
252
|
+
addHandler("waiting", () => {
|
|
253
|
+
api.setState("waiting", true);
|
|
254
|
+
api.setState("buffering", true);
|
|
255
|
+
api.emit("media:waiting", void 0);
|
|
256
|
+
});
|
|
257
|
+
addHandler("canplay", () => {
|
|
258
|
+
api.setState("waiting", false);
|
|
259
|
+
api.setState("playbackState", "ready");
|
|
260
|
+
api.emit("media:canplay", void 0);
|
|
261
|
+
});
|
|
262
|
+
addHandler("canplaythrough", () => {
|
|
263
|
+
api.setState("buffering", false);
|
|
264
|
+
api.emit("media:canplaythrough", void 0);
|
|
265
|
+
});
|
|
266
|
+
addHandler("progress", () => {
|
|
267
|
+
if (video.buffered.length > 0) {
|
|
268
|
+
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
269
|
+
const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
|
|
270
|
+
api.setState("bufferedAmount", bufferedAmount);
|
|
271
|
+
api.setState("buffered", video.buffered);
|
|
272
|
+
api.emit("media:progress", { buffered: bufferedAmount });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
addHandler("seeking", () => {
|
|
276
|
+
api.setState("seeking", true);
|
|
277
|
+
});
|
|
278
|
+
addHandler("seeked", () => {
|
|
279
|
+
api.setState("seeking", false);
|
|
280
|
+
api.emit("playback:seeked", { time: video.currentTime });
|
|
281
|
+
});
|
|
282
|
+
addHandler("volumechange", () => {
|
|
283
|
+
api.setState("volume", video.volume);
|
|
284
|
+
api.setState("muted", video.muted);
|
|
285
|
+
api.emit("volume:change", { volume: video.volume, muted: video.muted });
|
|
286
|
+
});
|
|
287
|
+
addHandler("ratechange", () => {
|
|
288
|
+
api.setState("playbackRate", video.playbackRate);
|
|
289
|
+
api.emit("playback:ratechange", { rate: video.playbackRate });
|
|
290
|
+
});
|
|
291
|
+
addHandler("loadedmetadata", () => {
|
|
292
|
+
api.setState("duration", video.duration);
|
|
293
|
+
api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
|
|
294
|
+
});
|
|
295
|
+
addHandler("loadeddata", () => {
|
|
296
|
+
if (video.videoWidth > 0) {
|
|
297
|
+
api.setState("mediaType", "video");
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
addHandler("error", () => {
|
|
301
|
+
const error = video.error;
|
|
302
|
+
if (error) {
|
|
303
|
+
api.logger.error("Video element error", { code: error.code, message: error.message });
|
|
304
|
+
api.emit("media:error", { error: new Error(error.message || "Video playback error") });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
addHandler("enterpictureinpicture", () => {
|
|
308
|
+
api.setState("pip", true);
|
|
309
|
+
api.logger.debug("PiP: entered (standard)");
|
|
310
|
+
});
|
|
311
|
+
addHandler("leavepictureinpicture", () => {
|
|
312
|
+
api.setState("pip", false);
|
|
313
|
+
api.logger.debug("PiP: exited (standard)");
|
|
314
|
+
if (!video.paused || api.getState("playing")) {
|
|
315
|
+
video.play().catch(() => {
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
const webkitVideo = video;
|
|
320
|
+
if ("webkitPresentationMode" in video) {
|
|
321
|
+
addHandler("webkitpresentationmodechanged", () => {
|
|
322
|
+
const mode = webkitVideo.webkitPresentationMode;
|
|
323
|
+
const isInPip = mode === "picture-in-picture";
|
|
324
|
+
api.setState("pip", isInPip);
|
|
325
|
+
api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
|
|
326
|
+
if (mode === "inline" && video.paused) {
|
|
327
|
+
video.play().catch(() => {
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return () => {
|
|
333
|
+
for (const { event, handler } of handlers) {
|
|
334
|
+
video.removeEventListener(event, handler);
|
|
335
|
+
}
|
|
336
|
+
handlers.length = 0;
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/playlist-validation.ts
|
|
341
|
+
var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
|
|
342
|
+
var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
|
|
343
|
+
function isValidPlaylistDocument(data, contextType) {
|
|
344
|
+
if (typeof data !== "string" || data.length === 0) return false;
|
|
345
|
+
const text = data.trimStart();
|
|
346
|
+
if (!text.startsWith("#EXTM3U")) return false;
|
|
347
|
+
if (contextType && MEDIA_PLAYLIST_CONTEXTS.includes(contextType)) {
|
|
348
|
+
return /^#EXT(?:INF|-X-TARGETDURATION):/m.test(text);
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
function createValidatingPlaylistLoader(Hls) {
|
|
353
|
+
const BaseLoader = Hls.DefaultConfig.loader;
|
|
354
|
+
return class ValidatingPlaylistLoader extends BaseLoader {
|
|
355
|
+
/**
|
|
356
|
+
* Load a playlist, validating the response document before it reaches
|
|
357
|
+
* the M3U8 parser.
|
|
358
|
+
*
|
|
359
|
+
* @param context - hls.js loader context
|
|
360
|
+
* @param config - hls.js loader config
|
|
361
|
+
* @param callbacks - hls.js loader callbacks
|
|
362
|
+
*/
|
|
363
|
+
load(context, config, callbacks) {
|
|
364
|
+
const wrapped = {
|
|
365
|
+
...callbacks,
|
|
366
|
+
onSuccess: (response, stats, ctx, networkDetails) => {
|
|
367
|
+
if (!isValidPlaylistDocument(response?.data, ctx?.type)) {
|
|
368
|
+
callbacks.onError(
|
|
369
|
+
{ code: 0, text: PLAYLIST_INVALID_TEXT },
|
|
370
|
+
ctx,
|
|
371
|
+
networkDetails,
|
|
372
|
+
stats
|
|
373
|
+
);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
callbacks.onSuccess(response, stats, ctx, networkDetails);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
super.load(context, config, wrapped);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/create-hls-plugin.ts
|
|
385
|
+
var DEFAULT_CONFIG = {
|
|
386
|
+
debug: false,
|
|
387
|
+
autoStartLoad: true,
|
|
388
|
+
startPosition: -1,
|
|
389
|
+
lowLatencyMode: false,
|
|
390
|
+
maxBufferLength: 30,
|
|
391
|
+
maxMaxBufferLength: 600,
|
|
392
|
+
backBufferLength: 30,
|
|
393
|
+
enableWorker: true,
|
|
394
|
+
capLevelToPlayerSize: true,
|
|
395
|
+
// Error recovery settings
|
|
396
|
+
maxNetworkRetries: 3,
|
|
397
|
+
maxMediaRetries: 2,
|
|
398
|
+
retryDelayMs: 1e3,
|
|
399
|
+
retryBackoffFactor: 2,
|
|
400
|
+
// Load watchdog: never leave the viewer on an endless spinner
|
|
401
|
+
loadTimeoutMs: 3e4,
|
|
402
|
+
// Self-healing: reconnect automatically after fatal errors mid-playback
|
|
403
|
+
autoReconnect: true,
|
|
404
|
+
reconnectBaseDelayMs: 2e3,
|
|
405
|
+
reconnectMaxDelayMs: 3e4,
|
|
406
|
+
reconnectWindowMs: 3e5,
|
|
407
|
+
// Never index a malformed live playlist refresh blindly
|
|
408
|
+
validatePlaylists: true
|
|
409
|
+
};
|
|
410
|
+
var MANIFEST_PHASE_ERRORS = [
|
|
411
|
+
"manifestLoadError",
|
|
412
|
+
"manifestLoadTimeOut",
|
|
413
|
+
"manifestParsingError"
|
|
414
|
+
];
|
|
415
|
+
function createHLSPluginWith(loader, variant, config) {
|
|
416
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
417
|
+
let api = null;
|
|
418
|
+
let hls = null;
|
|
419
|
+
let video = null;
|
|
420
|
+
let isNative = false;
|
|
421
|
+
let currentSrc = null;
|
|
422
|
+
let cleanupHlsEvents = null;
|
|
423
|
+
let cleanupVideoEvents = null;
|
|
424
|
+
let isAutoQuality = true;
|
|
425
|
+
let loadSession = 0;
|
|
426
|
+
let abortPendingLoad = null;
|
|
427
|
+
let networkRetryCount = 0;
|
|
428
|
+
let mediaRetryCount = 0;
|
|
429
|
+
let retryTimeout = null;
|
|
430
|
+
let errorCount = 0;
|
|
431
|
+
let errorWindowStart = 0;
|
|
432
|
+
const MAX_ERRORS_IN_WINDOW = 10;
|
|
433
|
+
const ERROR_WINDOW_MS = 5e3;
|
|
434
|
+
let hasPlayedContent = false;
|
|
435
|
+
let reconnectTimer = null;
|
|
436
|
+
let reconnectAttempts = 0;
|
|
437
|
+
let reconnectWindowStart = 0;
|
|
438
|
+
let reconnectResumePosition = 0;
|
|
439
|
+
let onlineListener = null;
|
|
440
|
+
const getOrCreateVideo = () => {
|
|
441
|
+
if (video) return video;
|
|
442
|
+
const existing = api?.container.querySelector("video");
|
|
443
|
+
if (existing) {
|
|
444
|
+
video = existing;
|
|
445
|
+
return video;
|
|
446
|
+
}
|
|
447
|
+
video = document.createElement("video");
|
|
448
|
+
video.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;display:block;object-fit:contain;background:#000";
|
|
449
|
+
video.preload = "metadata";
|
|
450
|
+
video.controls = false;
|
|
451
|
+
video.playsInline = true;
|
|
452
|
+
const poster = api?.getState("poster");
|
|
453
|
+
if (poster) {
|
|
454
|
+
video.poster = poster;
|
|
455
|
+
}
|
|
456
|
+
api?.container.appendChild(video);
|
|
457
|
+
return video;
|
|
458
|
+
};
|
|
459
|
+
const teardownPipeline = (reason) => {
|
|
460
|
+
abortPendingLoad?.(reason ?? new Error("HLS load cancelled"));
|
|
461
|
+
abortPendingLoad = null;
|
|
462
|
+
cleanupHlsEvents?.();
|
|
463
|
+
cleanupHlsEvents = null;
|
|
464
|
+
cleanupVideoEvents?.();
|
|
465
|
+
cleanupVideoEvents = null;
|
|
466
|
+
if (retryTimeout) {
|
|
467
|
+
clearTimeout(retryTimeout);
|
|
468
|
+
retryTimeout = null;
|
|
469
|
+
}
|
|
470
|
+
if (hls) {
|
|
471
|
+
hls.destroy();
|
|
472
|
+
hls = null;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
const cleanup = (reason) => {
|
|
476
|
+
teardownPipeline(reason);
|
|
477
|
+
currentSrc = null;
|
|
478
|
+
isNative = false;
|
|
479
|
+
isAutoQuality = true;
|
|
480
|
+
networkRetryCount = 0;
|
|
481
|
+
mediaRetryCount = 0;
|
|
482
|
+
errorCount = 0;
|
|
483
|
+
errorWindowStart = 0;
|
|
484
|
+
};
|
|
485
|
+
const buildHlsConfig = () => {
|
|
486
|
+
const config2 = buildBaseHlsConfig();
|
|
487
|
+
if (mergedConfig.validatePlaylists !== false) {
|
|
488
|
+
const Hls = loader.getHlsConstructor();
|
|
489
|
+
if (Hls && Hls.DefaultConfig?.loader) {
|
|
490
|
+
config2.pLoader = createValidatingPlaylistLoader(Hls);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return config2;
|
|
494
|
+
};
|
|
495
|
+
const buildBaseHlsConfig = () => ({
|
|
496
|
+
debug: mergedConfig.debug,
|
|
497
|
+
autoStartLoad: mergedConfig.autoStartLoad,
|
|
498
|
+
startPosition: mergedConfig.startPosition,
|
|
499
|
+
startLevel: -1,
|
|
500
|
+
// Auto quality selection (ABR)
|
|
501
|
+
abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
|
|
502
|
+
lowLatencyMode: mergedConfig.lowLatencyMode,
|
|
503
|
+
maxBufferLength: mergedConfig.maxBufferLength,
|
|
504
|
+
maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
|
|
505
|
+
backBufferLength: mergedConfig.backBufferLength,
|
|
506
|
+
enableWorker: mergedConfig.enableWorker,
|
|
507
|
+
capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
|
|
508
|
+
// Minimize hls.js internal retries - we handle retries ourselves
|
|
509
|
+
fragLoadingMaxRetry: 1,
|
|
510
|
+
manifestLoadingMaxRetry: 1,
|
|
511
|
+
levelLoadingMaxRetry: 1,
|
|
512
|
+
fragLoadingRetryDelay: 500,
|
|
513
|
+
manifestLoadingRetryDelay: 500,
|
|
514
|
+
levelLoadingRetryDelay: 500
|
|
515
|
+
});
|
|
516
|
+
const getRetryDelay = (retryCount) => {
|
|
517
|
+
const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
|
|
518
|
+
const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
|
|
519
|
+
const delay = baseDelay * Math.pow(backoffFactor, retryCount);
|
|
520
|
+
const jitter = delay * (0.7 + Math.random() * 0.3);
|
|
521
|
+
return jitter;
|
|
522
|
+
};
|
|
523
|
+
const APPEND_ERROR_DETAILS = [
|
|
524
|
+
"bufferAppendError",
|
|
525
|
+
"bufferAppendingError",
|
|
526
|
+
"bufferAddCodecError"
|
|
527
|
+
];
|
|
528
|
+
const mapFatalErrorCode = (error) => {
|
|
529
|
+
if (error.response?.text === PLAYLIST_INVALID_TEXT) {
|
|
530
|
+
return ErrorCode.PLAYLIST_INVALID;
|
|
531
|
+
}
|
|
532
|
+
if (error.details === "bufferFullError") {
|
|
533
|
+
return ErrorCode.MEDIA_BUFFER_FULL;
|
|
534
|
+
}
|
|
535
|
+
if (APPEND_ERROR_DETAILS.includes(error.details)) {
|
|
536
|
+
return ErrorCode.MEDIA_APPEND_ERROR;
|
|
537
|
+
}
|
|
538
|
+
switch (error.type) {
|
|
539
|
+
case "network":
|
|
540
|
+
return ErrorCode.MEDIA_NETWORK_ERROR;
|
|
541
|
+
case "media":
|
|
542
|
+
case "mux":
|
|
543
|
+
return ErrorCode.MEDIA_DECODE_ERROR;
|
|
544
|
+
default:
|
|
545
|
+
return ErrorCode.PLAYBACK_FAILED;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const emitFatalError = (error, retriesExhausted) => {
|
|
549
|
+
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
550
|
+
api?.logger.error(message, { type: error.type, details: error.details });
|
|
551
|
+
api?.setState("playbackState", "error");
|
|
552
|
+
api?.setState("buffering", false);
|
|
553
|
+
api?.emit("error", {
|
|
554
|
+
code: mapFatalErrorCode(error),
|
|
555
|
+
message,
|
|
556
|
+
fatal: true,
|
|
557
|
+
timestamp: Date.now()
|
|
558
|
+
});
|
|
559
|
+
maybeScheduleReconnect(error);
|
|
560
|
+
};
|
|
561
|
+
const handleHlsError = (error) => {
|
|
562
|
+
const Hls = loader.getHlsConstructor();
|
|
563
|
+
if (!Hls || !hls) return false;
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
if (now - errorWindowStart > ERROR_WINDOW_MS) {
|
|
566
|
+
errorCount = 1;
|
|
567
|
+
errorWindowStart = now;
|
|
568
|
+
} else {
|
|
569
|
+
errorCount++;
|
|
570
|
+
}
|
|
571
|
+
if (errorCount >= MAX_ERRORS_IN_WINDOW) {
|
|
572
|
+
api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
|
|
573
|
+
emitFatalError(error, true);
|
|
574
|
+
teardownPipeline(new Error(error.details));
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
if (error.fatal) {
|
|
578
|
+
api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
|
|
579
|
+
switch (error.type) {
|
|
580
|
+
case "network": {
|
|
581
|
+
const maxRetries = mergedConfig.maxNetworkRetries ?? 3;
|
|
582
|
+
if (networkRetryCount >= maxRetries) {
|
|
583
|
+
api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
|
|
584
|
+
emitFatalError(error, true);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
networkRetryCount++;
|
|
588
|
+
const delay = getRetryDelay(networkRetryCount - 1);
|
|
589
|
+
api?.logger.info(`Attempting network error recovery (attempt ${networkRetryCount}/${maxRetries}) in ${delay}ms`);
|
|
590
|
+
api?.emit("error:network", { error: new Error(error.details) });
|
|
591
|
+
if (retryTimeout) {
|
|
592
|
+
clearTimeout(retryTimeout);
|
|
593
|
+
}
|
|
594
|
+
const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
|
|
595
|
+
const retry_session = loadSession;
|
|
596
|
+
retryTimeout = setTimeout(() => {
|
|
597
|
+
if (retry_session !== loadSession || !hls) return;
|
|
598
|
+
if (isManifestPhase && currentSrc) {
|
|
599
|
+
hls.loadSource(currentSrc);
|
|
600
|
+
} else {
|
|
601
|
+
hls.startLoad();
|
|
602
|
+
}
|
|
603
|
+
}, delay);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case "media": {
|
|
607
|
+
const maxRetries = mergedConfig.maxMediaRetries ?? 2;
|
|
608
|
+
if (mediaRetryCount >= maxRetries) {
|
|
609
|
+
api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
|
|
610
|
+
emitFatalError(error, true);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
mediaRetryCount++;
|
|
614
|
+
const delay = getRetryDelay(mediaRetryCount - 1);
|
|
615
|
+
api?.logger.info(`Attempting media error recovery (attempt ${mediaRetryCount}/${maxRetries}) in ${delay}ms`);
|
|
616
|
+
api?.emit("error:media", { error: new Error(error.details) });
|
|
617
|
+
if (retryTimeout) {
|
|
618
|
+
clearTimeout(retryTimeout);
|
|
619
|
+
}
|
|
620
|
+
const retry_session = loadSession;
|
|
621
|
+
retryTimeout = setTimeout(() => {
|
|
622
|
+
if (retry_session !== loadSession || !hls) return;
|
|
623
|
+
hls.recoverMediaError();
|
|
624
|
+
}, delay);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
default:
|
|
628
|
+
emitFatalError(error, false);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
};
|
|
634
|
+
const loadNative = async (src) => {
|
|
635
|
+
const session = loadSession;
|
|
636
|
+
const videoEl = getOrCreateVideo();
|
|
637
|
+
isNative = true;
|
|
638
|
+
if (api) {
|
|
639
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
640
|
+
}
|
|
641
|
+
return new Promise((resolve, reject) => {
|
|
642
|
+
let watchdog = null;
|
|
643
|
+
let settled = false;
|
|
644
|
+
const settle = () => {
|
|
645
|
+
settled = true;
|
|
646
|
+
if (abortPendingLoad === abort) {
|
|
647
|
+
abortPendingLoad = null;
|
|
648
|
+
}
|
|
649
|
+
videoEl.removeEventListener("loadedmetadata", onLoaded);
|
|
650
|
+
videoEl.removeEventListener("error", onError);
|
|
651
|
+
if (watchdog !== null) {
|
|
652
|
+
clearTimeout(watchdog);
|
|
653
|
+
watchdog = null;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const abort = (reason) => {
|
|
657
|
+
if (settled) return;
|
|
658
|
+
settle();
|
|
659
|
+
reject(reason);
|
|
660
|
+
};
|
|
661
|
+
abortPendingLoad = abort;
|
|
662
|
+
const onLoaded = () => {
|
|
663
|
+
if (settled) return;
|
|
664
|
+
if (session !== loadSession) {
|
|
665
|
+
settle();
|
|
666
|
+
reject(new Error("HLS load cancelled"));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
settle();
|
|
670
|
+
hasPlayedContent = true;
|
|
671
|
+
const onFatalVideoError = () => {
|
|
672
|
+
const media_error = videoEl.error;
|
|
673
|
+
const hls_error = {
|
|
674
|
+
type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
|
|
675
|
+
details: media_error?.message || "Native HLS playback error",
|
|
676
|
+
fatal: true
|
|
677
|
+
};
|
|
678
|
+
emitFatalError(hls_error, false);
|
|
679
|
+
};
|
|
680
|
+
videoEl.addEventListener("error", onFatalVideoError);
|
|
681
|
+
const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
|
|
682
|
+
const previous_cleanup = cleanupVideoEvents;
|
|
683
|
+
cleanupVideoEvents = () => {
|
|
684
|
+
removeFatalListener();
|
|
685
|
+
previous_cleanup?.();
|
|
686
|
+
};
|
|
687
|
+
api?.setState("source", { src, type: "application/x-mpegURL" });
|
|
688
|
+
api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
|
|
689
|
+
resolve();
|
|
690
|
+
};
|
|
691
|
+
const onError = () => {
|
|
692
|
+
if (settled) return;
|
|
693
|
+
settle();
|
|
694
|
+
const error = videoEl.error;
|
|
695
|
+
reject(new Error(error?.message || "Failed to load HLS source"));
|
|
696
|
+
};
|
|
697
|
+
const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
|
|
698
|
+
if (timeout_ms > 0) {
|
|
699
|
+
watchdog = setTimeout(() => {
|
|
700
|
+
if (settled || session !== loadSession) return;
|
|
701
|
+
settle();
|
|
702
|
+
reject(new Error("Video took too long to load (network timeout)"));
|
|
703
|
+
}, timeout_ms);
|
|
704
|
+
}
|
|
705
|
+
videoEl.addEventListener("loadedmetadata", onLoaded);
|
|
706
|
+
videoEl.addEventListener("error", onError);
|
|
707
|
+
videoEl.src = src;
|
|
708
|
+
videoEl.load();
|
|
709
|
+
});
|
|
710
|
+
};
|
|
711
|
+
const loadWithHlsJs = async (src) => {
|
|
712
|
+
const session = loadSession;
|
|
713
|
+
await loader.loadHlsJs();
|
|
714
|
+
if (session !== loadSession) {
|
|
715
|
+
throw new Error("HLS load cancelled");
|
|
716
|
+
}
|
|
717
|
+
const videoEl = getOrCreateVideo();
|
|
718
|
+
isNative = false;
|
|
719
|
+
hls = loader.createHlsInstance(buildHlsConfig());
|
|
720
|
+
if (api) {
|
|
721
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
722
|
+
}
|
|
723
|
+
return new Promise((resolve, reject) => {
|
|
724
|
+
if (!hls || !api) {
|
|
725
|
+
reject(new Error("HLS not initialized"));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
let resolved = false;
|
|
729
|
+
let watchdog = null;
|
|
730
|
+
const clearWatchdog = () => {
|
|
731
|
+
if (watchdog !== null) {
|
|
732
|
+
clearTimeout(watchdog);
|
|
733
|
+
watchdog = null;
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
const abort = (reason) => {
|
|
737
|
+
if (resolved) return;
|
|
738
|
+
resolved = true;
|
|
739
|
+
clearWatchdog();
|
|
740
|
+
reject(reason);
|
|
741
|
+
};
|
|
742
|
+
abortPendingLoad = abort;
|
|
743
|
+
const releaseAbort = () => {
|
|
744
|
+
if (abortPendingLoad === abort) {
|
|
745
|
+
abortPendingLoad = null;
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
|
|
749
|
+
onManifestParsed: () => {
|
|
750
|
+
if (session !== loadSession) return;
|
|
751
|
+
if (!resolved) {
|
|
752
|
+
resolved = true;
|
|
753
|
+
releaseAbort();
|
|
754
|
+
clearWatchdog();
|
|
755
|
+
hasPlayedContent = true;
|
|
756
|
+
api?.setState("source", { src, type: "application/x-mpegURL" });
|
|
757
|
+
api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
|
|
758
|
+
resolve();
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
onLevelSwitched: () => {
|
|
762
|
+
},
|
|
763
|
+
onError: (error) => {
|
|
764
|
+
if (session !== loadSession) return;
|
|
765
|
+
const terminal = handleHlsError(error);
|
|
766
|
+
if (terminal && !resolved) {
|
|
767
|
+
resolved = true;
|
|
768
|
+
releaseAbort();
|
|
769
|
+
clearWatchdog();
|
|
770
|
+
reject(new Error(error.details));
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
onFragLoaded: () => {
|
|
774
|
+
if (session !== loadSession) return;
|
|
775
|
+
if (networkRetryCount > 0 || mediaRetryCount > 0) {
|
|
776
|
+
api?.logger.debug("Playback recovered, resetting retry budgets");
|
|
777
|
+
networkRetryCount = 0;
|
|
778
|
+
mediaRetryCount = 0;
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
getIsAutoQuality: () => isAutoQuality
|
|
782
|
+
});
|
|
783
|
+
const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
|
|
784
|
+
if (timeout_ms > 0) {
|
|
785
|
+
watchdog = setTimeout(() => {
|
|
786
|
+
if (resolved || session !== loadSession) return;
|
|
787
|
+
resolved = true;
|
|
788
|
+
releaseAbort();
|
|
789
|
+
api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
|
|
790
|
+
teardownPipeline();
|
|
791
|
+
reject(new Error("Video took too long to load (network timeout)"));
|
|
792
|
+
}, timeout_ms);
|
|
793
|
+
}
|
|
794
|
+
hls.attachMedia(videoEl);
|
|
795
|
+
hls.loadSource(src);
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
const cancelReconnect = () => {
|
|
799
|
+
if (reconnectTimer) {
|
|
800
|
+
clearTimeout(reconnectTimer);
|
|
801
|
+
reconnectTimer = null;
|
|
802
|
+
}
|
|
803
|
+
reconnectAttempts = 0;
|
|
804
|
+
reconnectWindowStart = 0;
|
|
805
|
+
reconnectResumePosition = 0;
|
|
806
|
+
};
|
|
807
|
+
const scheduleReconnectAttempt = () => {
|
|
808
|
+
if (reconnectTimer) return;
|
|
809
|
+
const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
|
|
810
|
+
if (Date.now() - reconnectWindowStart > window_ms) {
|
|
811
|
+
api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
|
|
815
|
+
const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
|
|
816
|
+
const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
|
|
817
|
+
const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
|
|
818
|
+
api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
|
|
819
|
+
api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
|
|
820
|
+
reconnectTimer = setTimeout(() => {
|
|
821
|
+
reconnectTimer = null;
|
|
822
|
+
void attemptReconnect();
|
|
823
|
+
}, delay);
|
|
824
|
+
};
|
|
825
|
+
const maybeScheduleReconnect = (error) => {
|
|
826
|
+
if (mergedConfig.autoReconnect === false) return;
|
|
827
|
+
if (!hasPlayedContent || !currentSrc) return;
|
|
828
|
+
if (error.type !== "network" && error.type !== "media") return;
|
|
829
|
+
if (reconnectWindowStart === 0) {
|
|
830
|
+
reconnectWindowStart = Date.now();
|
|
831
|
+
reconnectResumePosition = video?.currentTime ?? 0;
|
|
832
|
+
}
|
|
833
|
+
scheduleReconnectAttempt();
|
|
834
|
+
};
|
|
835
|
+
const attemptReconnect = async () => {
|
|
836
|
+
if (!api || !currentSrc) return;
|
|
837
|
+
const session = ++loadSession;
|
|
838
|
+
reconnectAttempts++;
|
|
839
|
+
const saved_src = currentSrc;
|
|
840
|
+
const was_live = api.getState("live");
|
|
841
|
+
const was_native = isNative;
|
|
842
|
+
const resume_position = reconnectResumePosition;
|
|
843
|
+
api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
|
|
844
|
+
try {
|
|
845
|
+
teardownPipeline(new Error("HLS load cancelled: reconnecting"));
|
|
846
|
+
networkRetryCount = 0;
|
|
847
|
+
mediaRetryCount = 0;
|
|
848
|
+
errorCount = 0;
|
|
849
|
+
errorWindowStart = 0;
|
|
850
|
+
currentSrc = saved_src;
|
|
851
|
+
api.setState("playbackState", "loading");
|
|
852
|
+
if (was_native && loader.supportsNativeHLS()) {
|
|
853
|
+
await loadNative(saved_src);
|
|
854
|
+
} else {
|
|
855
|
+
await loadWithHlsJs(saved_src);
|
|
856
|
+
}
|
|
857
|
+
if (session !== loadSession) return;
|
|
858
|
+
if (!was_live && video && resume_position > 0) {
|
|
859
|
+
video.currentTime = resume_position;
|
|
860
|
+
}
|
|
861
|
+
api.setState("playbackState", "ready");
|
|
862
|
+
api.setState("buffering", false);
|
|
863
|
+
api.emit("error:recovered", void 0);
|
|
864
|
+
api.logger.info("Auto-reconnect succeeded");
|
|
865
|
+
cancelReconnect();
|
|
866
|
+
try {
|
|
867
|
+
await video?.play();
|
|
868
|
+
} catch {
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
if (session !== loadSession) return;
|
|
872
|
+
api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
|
|
873
|
+
scheduleReconnectAttempt();
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
const plugin = {
|
|
877
|
+
id: "hls-provider",
|
|
878
|
+
name: variant.name,
|
|
879
|
+
version: "1.0.0",
|
|
880
|
+
type: "provider",
|
|
881
|
+
description: variant.description,
|
|
882
|
+
canPlay(src) {
|
|
883
|
+
if (!loader.isHLSSupported()) return false;
|
|
884
|
+
const url = src.toLowerCase();
|
|
885
|
+
const urlWithoutQuery = url.split("?")[0].split("#")[0];
|
|
886
|
+
if (urlWithoutQuery.endsWith(".m3u8")) return true;
|
|
887
|
+
if (url.includes("application/x-mpegurl")) return true;
|
|
888
|
+
if (url.includes("application/vnd.apple.mpegurl")) return true;
|
|
889
|
+
return false;
|
|
890
|
+
},
|
|
891
|
+
async init(pluginApi) {
|
|
892
|
+
api = pluginApi;
|
|
893
|
+
api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
|
|
894
|
+
const unsubPlay = api.on("playback:play", async () => {
|
|
895
|
+
if (!video) return;
|
|
896
|
+
try {
|
|
897
|
+
await video.play();
|
|
898
|
+
} catch (e) {
|
|
899
|
+
api?.logger.error("Play failed", e);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
const unsubPause = api.on("playback:pause", () => {
|
|
903
|
+
video?.pause();
|
|
904
|
+
});
|
|
905
|
+
const unsubSeek = api.on("playback:seeking", ({ time }) => {
|
|
906
|
+
if (!video) return;
|
|
907
|
+
const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
|
|
908
|
+
video.currentTime = clampedTime;
|
|
909
|
+
});
|
|
910
|
+
const unsubVolume = api.on("volume:change", ({ volume }) => {
|
|
911
|
+
if (video) video.volume = volume;
|
|
912
|
+
});
|
|
913
|
+
const unsubMute = api.on("volume:mute", ({ muted }) => {
|
|
914
|
+
if (video) video.muted = muted;
|
|
915
|
+
});
|
|
916
|
+
const unsubRate = api.on("playback:ratechange", ({ rate }) => {
|
|
917
|
+
if (video) video.playbackRate = rate;
|
|
918
|
+
});
|
|
919
|
+
const unsubQuality = api.on("quality:select", ({ quality, auto }) => {
|
|
920
|
+
if (!hls || isNative) {
|
|
921
|
+
api?.logger.warn("Quality selection not available");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (auto || quality === "auto") {
|
|
925
|
+
isAutoQuality = true;
|
|
926
|
+
hls.currentLevel = -1;
|
|
927
|
+
api?.logger.debug("Quality: auto selection enabled");
|
|
928
|
+
api?.setState("currentQuality", {
|
|
929
|
+
id: "auto",
|
|
930
|
+
label: "Auto",
|
|
931
|
+
width: 0,
|
|
932
|
+
height: 0,
|
|
933
|
+
bitrate: 0,
|
|
934
|
+
active: true
|
|
935
|
+
});
|
|
936
|
+
} else {
|
|
937
|
+
isAutoQuality = false;
|
|
938
|
+
const levelIndex = parseInt(quality.replace("level-", ""), 10);
|
|
939
|
+
if (!isNaN(levelIndex) && levelIndex >= 0 && levelIndex < hls.levels.length) {
|
|
940
|
+
hls.nextLevel = levelIndex;
|
|
941
|
+
api?.logger.debug(`Quality: queued switch to level ${levelIndex}`);
|
|
942
|
+
const targetLevel = hls.levels[levelIndex];
|
|
943
|
+
if (targetLevel) {
|
|
944
|
+
const label = formatLevel(targetLevel);
|
|
945
|
+
api?.setState("currentQuality", {
|
|
946
|
+
id: `level-${levelIndex}`,
|
|
947
|
+
label: `${label}...`,
|
|
948
|
+
// Ellipsis indicates switching in progress
|
|
949
|
+
width: targetLevel.width,
|
|
950
|
+
height: targetLevel.height,
|
|
951
|
+
bitrate: targetLevel.bitrate,
|
|
952
|
+
active: false
|
|
953
|
+
// Not yet active
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
if (typeof window !== "undefined") {
|
|
960
|
+
onlineListener = () => {
|
|
961
|
+
if (reconnectTimer) {
|
|
962
|
+
api?.logger.info("Browser back online, reconnecting immediately");
|
|
963
|
+
clearTimeout(reconnectTimer);
|
|
964
|
+
reconnectTimer = null;
|
|
965
|
+
void attemptReconnect();
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
window.addEventListener("online", onlineListener);
|
|
969
|
+
}
|
|
970
|
+
api.onDestroy(() => {
|
|
971
|
+
unsubPlay();
|
|
972
|
+
unsubPause();
|
|
973
|
+
unsubSeek();
|
|
974
|
+
unsubVolume();
|
|
975
|
+
unsubMute();
|
|
976
|
+
unsubRate();
|
|
977
|
+
unsubQuality();
|
|
978
|
+
});
|
|
979
|
+
},
|
|
980
|
+
async destroy() {
|
|
981
|
+
api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
|
|
982
|
+
loadSession++;
|
|
983
|
+
cancelReconnect();
|
|
984
|
+
if (onlineListener && typeof window !== "undefined") {
|
|
985
|
+
window.removeEventListener("online", onlineListener);
|
|
986
|
+
onlineListener = null;
|
|
987
|
+
}
|
|
988
|
+
cleanup(new Error("HLS load cancelled: player destroyed"));
|
|
989
|
+
if (video?.parentNode) {
|
|
990
|
+
video.parentNode.removeChild(video);
|
|
991
|
+
}
|
|
992
|
+
video = null;
|
|
993
|
+
api = null;
|
|
994
|
+
},
|
|
995
|
+
async loadSource(src) {
|
|
996
|
+
if (!api) throw new Error("Plugin not initialized");
|
|
997
|
+
api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
|
|
998
|
+
const session = ++loadSession;
|
|
999
|
+
cancelReconnect();
|
|
1000
|
+
hasPlayedContent = false;
|
|
1001
|
+
cleanup(new Error("HLS load cancelled: superseded by a new load"));
|
|
1002
|
+
currentSrc = src;
|
|
1003
|
+
api.setState("playbackState", "loading");
|
|
1004
|
+
api.setState("buffering", true);
|
|
1005
|
+
if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
|
|
1006
|
+
api.logger.info("Using native HLS (AirPlay active)");
|
|
1007
|
+
await loadNative(src);
|
|
1008
|
+
} else if (loader.isHlsJsSupported()) {
|
|
1009
|
+
api.logger.info(`Using ${variant.engineLabel} for HLS playback`);
|
|
1010
|
+
await loadWithHlsJs(src);
|
|
1011
|
+
} else if (loader.supportsNativeHLS()) {
|
|
1012
|
+
api.logger.info("Using native HLS playback (hls.js not supported)");
|
|
1013
|
+
await loadNative(src);
|
|
1014
|
+
} else {
|
|
1015
|
+
throw new Error("HLS playback not supported in this browser");
|
|
1016
|
+
}
|
|
1017
|
+
if (session !== loadSession) return;
|
|
1018
|
+
if (video) {
|
|
1019
|
+
const muted = api.getState("muted");
|
|
1020
|
+
const volume = api.getState("volume");
|
|
1021
|
+
if (muted !== void 0) video.muted = muted;
|
|
1022
|
+
if (volume !== void 0) video.volume = volume;
|
|
1023
|
+
}
|
|
1024
|
+
api.setState("playbackState", "ready");
|
|
1025
|
+
api.setState("buffering", false);
|
|
1026
|
+
},
|
|
1027
|
+
getCurrentLevel() {
|
|
1028
|
+
if (isNative || !hls) return -1;
|
|
1029
|
+
return hls.currentLevel;
|
|
1030
|
+
},
|
|
1031
|
+
setLevel(index) {
|
|
1032
|
+
if (isNative || !hls) {
|
|
1033
|
+
api?.logger.warn("Quality selection not available in native HLS mode");
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
hls.currentLevel = index;
|
|
1037
|
+
},
|
|
1038
|
+
getLevels() {
|
|
1039
|
+
if (isNative || !hls) return [];
|
|
1040
|
+
return mapLevels(hls.levels, hls.currentLevel);
|
|
1041
|
+
},
|
|
1042
|
+
getHlsInstance() {
|
|
1043
|
+
return hls;
|
|
1044
|
+
},
|
|
1045
|
+
isNativeHLS() {
|
|
1046
|
+
return isNative;
|
|
1047
|
+
},
|
|
1048
|
+
getLiveInfo() {
|
|
1049
|
+
if (isNative || !hls) return null;
|
|
1050
|
+
const live = api?.getState("live") || false;
|
|
1051
|
+
if (!live) return null;
|
|
1052
|
+
return {
|
|
1053
|
+
isLive: true,
|
|
1054
|
+
latency: hls.latency || 0,
|
|
1055
|
+
targetLatency: hls.targetLatency || 3,
|
|
1056
|
+
drift: hls.drift || 0
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
/**
|
|
1060
|
+
* Switch from hls.js to native HLS playback.
|
|
1061
|
+
* Used for AirPlay compatibility in Safari.
|
|
1062
|
+
* Preserves current playback position.
|
|
1063
|
+
*/
|
|
1064
|
+
async switchToNative() {
|
|
1065
|
+
if (isNative) {
|
|
1066
|
+
api?.logger.debug("Already using native HLS");
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (!loader.supportsNativeHLS()) {
|
|
1070
|
+
api?.logger.warn("Native HLS not supported in this browser");
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (!currentSrc) {
|
|
1074
|
+
api?.logger.warn("No source loaded");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
api?.logger.info("Switching to native HLS for AirPlay");
|
|
1078
|
+
const wasPlaying = api?.getState("playing") || false;
|
|
1079
|
+
const currentTime = video?.currentTime || 0;
|
|
1080
|
+
const savedSrc = currentSrc;
|
|
1081
|
+
const session = ++loadSession;
|
|
1082
|
+
cleanup(new Error("HLS load cancelled: switching to native HLS"));
|
|
1083
|
+
await loadNative(savedSrc);
|
|
1084
|
+
if (session !== loadSession) return;
|
|
1085
|
+
if (video && currentTime > 0) {
|
|
1086
|
+
video.currentTime = currentTime;
|
|
1087
|
+
}
|
|
1088
|
+
if (wasPlaying && video) {
|
|
1089
|
+
try {
|
|
1090
|
+
await video.play();
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
api?.logger.debug("Could not auto-resume after switch");
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
api?.logger.info("Switched to native HLS");
|
|
1096
|
+
},
|
|
1097
|
+
/**
|
|
1098
|
+
* Switch from native HLS back to hls.js.
|
|
1099
|
+
* Restores quality control after AirPlay session ends.
|
|
1100
|
+
*/
|
|
1101
|
+
async switchToHlsJs() {
|
|
1102
|
+
if (!isNative) {
|
|
1103
|
+
api?.logger.debug("Already using hls.js");
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (!loader.isHlsJsSupported()) {
|
|
1107
|
+
api?.logger.warn("hls.js not supported in this browser");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (!currentSrc) {
|
|
1111
|
+
api?.logger.warn("No source loaded");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
api?.logger.info("Switching back to hls.js");
|
|
1115
|
+
const wasPlaying = api?.getState("playing") || false;
|
|
1116
|
+
const currentTime = video?.currentTime || 0;
|
|
1117
|
+
const savedSrc = currentSrc;
|
|
1118
|
+
const session = ++loadSession;
|
|
1119
|
+
cleanup(new Error("HLS load cancelled: switching to hls.js"));
|
|
1120
|
+
await loadWithHlsJs(savedSrc);
|
|
1121
|
+
if (session !== loadSession) return;
|
|
1122
|
+
if (video && currentTime > 0) {
|
|
1123
|
+
video.currentTime = currentTime;
|
|
1124
|
+
}
|
|
1125
|
+
if (wasPlaying && video) {
|
|
1126
|
+
try {
|
|
1127
|
+
await video.play();
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
api?.logger.debug("Could not auto-resume after switch");
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
api?.logger.info("Switched to hls.js");
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
return plugin;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export {
|
|
1139
|
+
__export,
|
|
1140
|
+
createHLSPluginWith
|
|
1141
|
+
};
|