@scarlett-player/hls 0.1.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/dist/index.cjs +804 -0
- package/dist/index.d.cts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +767 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
// src/hls-loader.ts
|
|
2
|
+
var hlsConstructor = null;
|
|
3
|
+
var loadingPromise = null;
|
|
4
|
+
function supportsNativeHLS() {
|
|
5
|
+
if (typeof document === "undefined") return false;
|
|
6
|
+
const video = document.createElement("video");
|
|
7
|
+
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
|
|
8
|
+
}
|
|
9
|
+
function isHlsJsSupported() {
|
|
10
|
+
if (hlsConstructor) {
|
|
11
|
+
return hlsConstructor.isSupported();
|
|
12
|
+
}
|
|
13
|
+
if (typeof window === "undefined") return false;
|
|
14
|
+
return !!(window.MediaSource || window.WebKitMediaSource);
|
|
15
|
+
}
|
|
16
|
+
function isHLSSupported() {
|
|
17
|
+
return supportsNativeHLS() || isHlsJsSupported();
|
|
18
|
+
}
|
|
19
|
+
async function loadHlsJs() {
|
|
20
|
+
if (hlsConstructor) {
|
|
21
|
+
return hlsConstructor;
|
|
22
|
+
}
|
|
23
|
+
if (loadingPromise) {
|
|
24
|
+
return loadingPromise;
|
|
25
|
+
}
|
|
26
|
+
loadingPromise = (async () => {
|
|
27
|
+
try {
|
|
28
|
+
const hlsModule = await import("hls.js");
|
|
29
|
+
hlsConstructor = hlsModule.default;
|
|
30
|
+
if (!hlsConstructor.isSupported()) {
|
|
31
|
+
throw new Error("hls.js is not supported in this browser");
|
|
32
|
+
}
|
|
33
|
+
return hlsConstructor;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
loadingPromise = null;
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Failed to load hls.js: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return loadingPromise;
|
|
42
|
+
}
|
|
43
|
+
function createHlsInstance(config) {
|
|
44
|
+
if (!hlsConstructor) {
|
|
45
|
+
throw new Error("hls.js is not loaded. Call loadHlsJs() first.");
|
|
46
|
+
}
|
|
47
|
+
return new hlsConstructor(config);
|
|
48
|
+
}
|
|
49
|
+
function getHlsConstructor() {
|
|
50
|
+
return hlsConstructor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/quality.ts
|
|
54
|
+
function formatLevel(level) {
|
|
55
|
+
if (level.name) {
|
|
56
|
+
return level.name;
|
|
57
|
+
}
|
|
58
|
+
if (level.height) {
|
|
59
|
+
const standardLabels = {
|
|
60
|
+
2160: "4K",
|
|
61
|
+
1440: "1440p",
|
|
62
|
+
1080: "1080p",
|
|
63
|
+
720: "720p",
|
|
64
|
+
480: "480p",
|
|
65
|
+
360: "360p",
|
|
66
|
+
240: "240p",
|
|
67
|
+
144: "144p"
|
|
68
|
+
};
|
|
69
|
+
const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
|
|
70
|
+
if (Math.abs(closest - level.height) <= 20) {
|
|
71
|
+
return standardLabels[closest];
|
|
72
|
+
}
|
|
73
|
+
return `${level.height}p`;
|
|
74
|
+
}
|
|
75
|
+
if (level.bitrate) {
|
|
76
|
+
return formatBitrate(level.bitrate);
|
|
77
|
+
}
|
|
78
|
+
return "Unknown";
|
|
79
|
+
}
|
|
80
|
+
function formatBitrate(bitrate) {
|
|
81
|
+
if (bitrate >= 1e6) {
|
|
82
|
+
return `${(bitrate / 1e6).toFixed(1)} Mbps`;
|
|
83
|
+
}
|
|
84
|
+
if (bitrate >= 1e3) {
|
|
85
|
+
return `${Math.round(bitrate / 1e3)} Kbps`;
|
|
86
|
+
}
|
|
87
|
+
return `${bitrate} bps`;
|
|
88
|
+
}
|
|
89
|
+
function mapLevels(levels, currentLevel) {
|
|
90
|
+
return levels.map((level, index) => ({
|
|
91
|
+
index,
|
|
92
|
+
width: level.width || 0,
|
|
93
|
+
height: level.height || 0,
|
|
94
|
+
bitrate: level.bitrate || 0,
|
|
95
|
+
label: formatLevel(level),
|
|
96
|
+
codec: level.codecSet
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/event-map.ts
|
|
101
|
+
var HLS_ERROR_TYPES = {
|
|
102
|
+
NETWORK_ERROR: "networkError",
|
|
103
|
+
MEDIA_ERROR: "mediaError",
|
|
104
|
+
KEY_SYSTEM_ERROR: "keySystemError",
|
|
105
|
+
MUX_ERROR: "muxError",
|
|
106
|
+
OTHER_ERROR: "otherError"
|
|
107
|
+
};
|
|
108
|
+
function mapErrorType(hlsType) {
|
|
109
|
+
switch (hlsType) {
|
|
110
|
+
case HLS_ERROR_TYPES.NETWORK_ERROR:
|
|
111
|
+
return "network";
|
|
112
|
+
case HLS_ERROR_TYPES.MEDIA_ERROR:
|
|
113
|
+
return "media";
|
|
114
|
+
case HLS_ERROR_TYPES.MUX_ERROR:
|
|
115
|
+
return "mux";
|
|
116
|
+
default:
|
|
117
|
+
return "other";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function parseHlsError(data) {
|
|
121
|
+
return {
|
|
122
|
+
type: mapErrorType(data.type),
|
|
123
|
+
details: data.details || "Unknown error",
|
|
124
|
+
fatal: data.fatal || false,
|
|
125
|
+
url: data.url,
|
|
126
|
+
reason: data.reason,
|
|
127
|
+
response: data.response
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function setupHlsEventHandlers(hls, api, callbacks) {
|
|
131
|
+
const handlers = [];
|
|
132
|
+
const addHandler = (event, handler) => {
|
|
133
|
+
hls.on(event, handler);
|
|
134
|
+
handlers.push({ event, handler });
|
|
135
|
+
};
|
|
136
|
+
addHandler("hlsManifestParsed", (_event, data) => {
|
|
137
|
+
api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
|
|
138
|
+
const levels = data.levels.map((level, index) => ({
|
|
139
|
+
id: `level-${index}`,
|
|
140
|
+
label: formatLevel(level),
|
|
141
|
+
width: level.width,
|
|
142
|
+
height: level.height,
|
|
143
|
+
bitrate: level.bitrate,
|
|
144
|
+
active: index === hls.currentLevel
|
|
145
|
+
}));
|
|
146
|
+
api.setState("qualities", levels);
|
|
147
|
+
api.emit("quality:levels", {
|
|
148
|
+
levels: levels.map((l) => ({ id: l.id, label: l.label }))
|
|
149
|
+
});
|
|
150
|
+
callbacks.onManifestParsed?.(data.levels);
|
|
151
|
+
});
|
|
152
|
+
addHandler("hlsLevelSwitched", (_event, data) => {
|
|
153
|
+
const level = hls.levels[data.level];
|
|
154
|
+
const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
|
|
155
|
+
api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
|
|
156
|
+
if (level) {
|
|
157
|
+
const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
|
|
158
|
+
api.setState("currentQuality", {
|
|
159
|
+
id: isAuto ? "auto" : `level-${data.level}`,
|
|
160
|
+
label,
|
|
161
|
+
width: level.width,
|
|
162
|
+
height: level.height,
|
|
163
|
+
bitrate: level.bitrate,
|
|
164
|
+
active: true
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
api.emit("quality:change", {
|
|
168
|
+
quality: level ? formatLevel(level) : "auto",
|
|
169
|
+
auto: isAuto
|
|
170
|
+
});
|
|
171
|
+
callbacks.onLevelSwitched?.(data.level);
|
|
172
|
+
});
|
|
173
|
+
addHandler("hlsFragBuffered", () => {
|
|
174
|
+
api.setState("buffering", false);
|
|
175
|
+
callbacks.onBufferUpdate?.();
|
|
176
|
+
});
|
|
177
|
+
addHandler("hlsFragLoading", () => {
|
|
178
|
+
api.setState("buffering", true);
|
|
179
|
+
});
|
|
180
|
+
addHandler("hlsLevelLoaded", (_event, data) => {
|
|
181
|
+
if (data.details?.live !== void 0) {
|
|
182
|
+
api.setState("live", data.details.live);
|
|
183
|
+
callbacks.onLiveUpdate?.();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
addHandler("hlsError", (_event, data) => {
|
|
187
|
+
const error = parseHlsError(data);
|
|
188
|
+
api.logger.warn("HLS error", { error });
|
|
189
|
+
callbacks.onError?.(error);
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
for (const { event, handler } of handlers) {
|
|
193
|
+
hls.off(event, handler);
|
|
194
|
+
}
|
|
195
|
+
handlers.length = 0;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function setupVideoEventHandlers(video, api) {
|
|
199
|
+
const handlers = [];
|
|
200
|
+
const addHandler = (event, handler) => {
|
|
201
|
+
video.addEventListener(event, handler);
|
|
202
|
+
handlers.push({ event, handler });
|
|
203
|
+
};
|
|
204
|
+
addHandler("playing", () => {
|
|
205
|
+
api.setState("playing", true);
|
|
206
|
+
api.setState("paused", false);
|
|
207
|
+
api.setState("playbackState", "playing");
|
|
208
|
+
});
|
|
209
|
+
addHandler("pause", () => {
|
|
210
|
+
api.setState("playing", false);
|
|
211
|
+
api.setState("paused", true);
|
|
212
|
+
api.setState("playbackState", "paused");
|
|
213
|
+
});
|
|
214
|
+
addHandler("ended", () => {
|
|
215
|
+
api.setState("playing", false);
|
|
216
|
+
api.setState("ended", true);
|
|
217
|
+
api.setState("playbackState", "ended");
|
|
218
|
+
api.emit("playback:ended", void 0);
|
|
219
|
+
});
|
|
220
|
+
addHandler("timeupdate", () => {
|
|
221
|
+
api.setState("currentTime", video.currentTime);
|
|
222
|
+
api.emit("playback:timeupdate", { currentTime: video.currentTime });
|
|
223
|
+
});
|
|
224
|
+
addHandler("durationchange", () => {
|
|
225
|
+
api.setState("duration", video.duration || 0);
|
|
226
|
+
api.emit("media:loadedmetadata", { duration: video.duration || 0 });
|
|
227
|
+
});
|
|
228
|
+
addHandler("waiting", () => {
|
|
229
|
+
api.setState("waiting", true);
|
|
230
|
+
api.setState("buffering", true);
|
|
231
|
+
api.emit("media:waiting", void 0);
|
|
232
|
+
});
|
|
233
|
+
addHandler("canplay", () => {
|
|
234
|
+
api.setState("waiting", false);
|
|
235
|
+
api.setState("playbackState", "ready");
|
|
236
|
+
api.emit("media:canplay", void 0);
|
|
237
|
+
});
|
|
238
|
+
addHandler("canplaythrough", () => {
|
|
239
|
+
api.setState("buffering", false);
|
|
240
|
+
api.emit("media:canplaythrough", void 0);
|
|
241
|
+
});
|
|
242
|
+
addHandler("progress", () => {
|
|
243
|
+
if (video.buffered.length > 0) {
|
|
244
|
+
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
245
|
+
const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
|
|
246
|
+
api.setState("bufferedAmount", bufferedAmount);
|
|
247
|
+
api.setState("buffered", video.buffered);
|
|
248
|
+
api.emit("media:progress", { buffered: bufferedAmount });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
addHandler("seeking", () => {
|
|
252
|
+
api.setState("seeking", true);
|
|
253
|
+
});
|
|
254
|
+
addHandler("seeked", () => {
|
|
255
|
+
api.setState("seeking", false);
|
|
256
|
+
api.emit("playback:seeked", { time: video.currentTime });
|
|
257
|
+
});
|
|
258
|
+
addHandler("volumechange", () => {
|
|
259
|
+
api.setState("volume", video.volume);
|
|
260
|
+
api.setState("muted", video.muted);
|
|
261
|
+
api.emit("volume:change", { volume: video.volume, muted: video.muted });
|
|
262
|
+
});
|
|
263
|
+
addHandler("ratechange", () => {
|
|
264
|
+
api.setState("playbackRate", video.playbackRate);
|
|
265
|
+
api.emit("playback:ratechange", { rate: video.playbackRate });
|
|
266
|
+
});
|
|
267
|
+
addHandler("loadedmetadata", () => {
|
|
268
|
+
api.setState("duration", video.duration);
|
|
269
|
+
api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
|
|
270
|
+
});
|
|
271
|
+
addHandler("error", () => {
|
|
272
|
+
const error = video.error;
|
|
273
|
+
if (error) {
|
|
274
|
+
api.logger.error("Video element error", { code: error.code, message: error.message });
|
|
275
|
+
api.emit("media:error", { error: new Error(error.message || "Video playback error") });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
addHandler("enterpictureinpicture", () => {
|
|
279
|
+
api.setState("pip", true);
|
|
280
|
+
api.logger.debug("PiP: entered (standard)");
|
|
281
|
+
});
|
|
282
|
+
addHandler("leavepictureinpicture", () => {
|
|
283
|
+
api.setState("pip", false);
|
|
284
|
+
api.logger.debug("PiP: exited (standard)");
|
|
285
|
+
if (!video.paused || api.getState("playing")) {
|
|
286
|
+
video.play().catch(() => {
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
const webkitVideo = video;
|
|
291
|
+
if ("webkitPresentationMode" in video) {
|
|
292
|
+
addHandler("webkitpresentationmodechanged", () => {
|
|
293
|
+
const mode = webkitVideo.webkitPresentationMode;
|
|
294
|
+
const isInPip = mode === "picture-in-picture";
|
|
295
|
+
api.setState("pip", isInPip);
|
|
296
|
+
api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
|
|
297
|
+
if (mode === "inline" && video.paused) {
|
|
298
|
+
video.play().catch(() => {
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return () => {
|
|
304
|
+
for (const { event, handler } of handlers) {
|
|
305
|
+
video.removeEventListener(event, handler);
|
|
306
|
+
}
|
|
307
|
+
handlers.length = 0;
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/index.ts
|
|
312
|
+
var DEFAULT_CONFIG = {
|
|
313
|
+
debug: false,
|
|
314
|
+
autoStartLoad: true,
|
|
315
|
+
startPosition: -1,
|
|
316
|
+
lowLatencyMode: false,
|
|
317
|
+
maxBufferLength: 30,
|
|
318
|
+
maxMaxBufferLength: 600,
|
|
319
|
+
backBufferLength: 30,
|
|
320
|
+
enableWorker: true,
|
|
321
|
+
// Error recovery settings
|
|
322
|
+
maxNetworkRetries: 3,
|
|
323
|
+
maxMediaRetries: 2,
|
|
324
|
+
retryDelayMs: 1e3,
|
|
325
|
+
retryBackoffFactor: 2
|
|
326
|
+
};
|
|
327
|
+
function createHLSPlugin(config) {
|
|
328
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
329
|
+
let api = null;
|
|
330
|
+
let hls = null;
|
|
331
|
+
let video = null;
|
|
332
|
+
let isNative = false;
|
|
333
|
+
let currentSrc = null;
|
|
334
|
+
let cleanupHlsEvents = null;
|
|
335
|
+
let cleanupVideoEvents = null;
|
|
336
|
+
let isAutoQuality = true;
|
|
337
|
+
let networkRetryCount = 0;
|
|
338
|
+
let mediaRetryCount = 0;
|
|
339
|
+
let retryTimeout = null;
|
|
340
|
+
let errorCount = 0;
|
|
341
|
+
let errorWindowStart = 0;
|
|
342
|
+
const MAX_ERRORS_IN_WINDOW = 10;
|
|
343
|
+
const ERROR_WINDOW_MS = 5e3;
|
|
344
|
+
const getOrCreateVideo = () => {
|
|
345
|
+
if (video) return video;
|
|
346
|
+
const existing = api?.container.querySelector("video");
|
|
347
|
+
if (existing) {
|
|
348
|
+
video = existing;
|
|
349
|
+
return video;
|
|
350
|
+
}
|
|
351
|
+
video = document.createElement("video");
|
|
352
|
+
video.style.cssText = "width:100%;height:100%;display:block;object-fit:contain;background:#000";
|
|
353
|
+
video.preload = "metadata";
|
|
354
|
+
video.controls = false;
|
|
355
|
+
video.playsInline = true;
|
|
356
|
+
api?.container.appendChild(video);
|
|
357
|
+
return video;
|
|
358
|
+
};
|
|
359
|
+
const cleanup = () => {
|
|
360
|
+
cleanupHlsEvents?.();
|
|
361
|
+
cleanupHlsEvents = null;
|
|
362
|
+
cleanupVideoEvents?.();
|
|
363
|
+
cleanupVideoEvents = null;
|
|
364
|
+
if (retryTimeout) {
|
|
365
|
+
clearTimeout(retryTimeout);
|
|
366
|
+
retryTimeout = null;
|
|
367
|
+
}
|
|
368
|
+
if (hls) {
|
|
369
|
+
hls.destroy();
|
|
370
|
+
hls = null;
|
|
371
|
+
}
|
|
372
|
+
currentSrc = null;
|
|
373
|
+
isNative = false;
|
|
374
|
+
isAutoQuality = true;
|
|
375
|
+
networkRetryCount = 0;
|
|
376
|
+
mediaRetryCount = 0;
|
|
377
|
+
errorCount = 0;
|
|
378
|
+
errorWindowStart = 0;
|
|
379
|
+
};
|
|
380
|
+
const buildHlsConfig = () => ({
|
|
381
|
+
debug: mergedConfig.debug,
|
|
382
|
+
autoStartLoad: mergedConfig.autoStartLoad,
|
|
383
|
+
startPosition: mergedConfig.startPosition,
|
|
384
|
+
startLevel: -1,
|
|
385
|
+
// Auto quality selection (ABR)
|
|
386
|
+
lowLatencyMode: mergedConfig.lowLatencyMode,
|
|
387
|
+
maxBufferLength: mergedConfig.maxBufferLength,
|
|
388
|
+
maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
|
|
389
|
+
backBufferLength: mergedConfig.backBufferLength,
|
|
390
|
+
enableWorker: mergedConfig.enableWorker,
|
|
391
|
+
// Minimize hls.js internal retries - we handle retries ourselves
|
|
392
|
+
fragLoadingMaxRetry: 1,
|
|
393
|
+
manifestLoadingMaxRetry: 1,
|
|
394
|
+
levelLoadingMaxRetry: 1,
|
|
395
|
+
fragLoadingRetryDelay: 500,
|
|
396
|
+
manifestLoadingRetryDelay: 500,
|
|
397
|
+
levelLoadingRetryDelay: 500
|
|
398
|
+
});
|
|
399
|
+
const getRetryDelay = (retryCount) => {
|
|
400
|
+
const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
|
|
401
|
+
const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
|
|
402
|
+
return baseDelay * Math.pow(backoffFactor, retryCount);
|
|
403
|
+
};
|
|
404
|
+
const emitFatalError = (error, retriesExhausted) => {
|
|
405
|
+
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
406
|
+
api?.logger.error(message, { type: error.type, details: error.details });
|
|
407
|
+
api?.setState("playbackState", "error");
|
|
408
|
+
api?.setState("buffering", false);
|
|
409
|
+
api?.emit("error", {
|
|
410
|
+
code: "MEDIA_ERROR",
|
|
411
|
+
message,
|
|
412
|
+
fatal: true,
|
|
413
|
+
timestamp: Date.now()
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
const handleHlsError = (error) => {
|
|
417
|
+
const Hls = getHlsConstructor();
|
|
418
|
+
if (!Hls || !hls) return;
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
if (now - errorWindowStart > ERROR_WINDOW_MS) {
|
|
421
|
+
errorCount = 1;
|
|
422
|
+
errorWindowStart = now;
|
|
423
|
+
} else {
|
|
424
|
+
errorCount++;
|
|
425
|
+
}
|
|
426
|
+
if (errorCount >= MAX_ERRORS_IN_WINDOW) {
|
|
427
|
+
api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
|
|
428
|
+
emitFatalError(error, true);
|
|
429
|
+
cleanupHlsEvents?.();
|
|
430
|
+
cleanupHlsEvents = null;
|
|
431
|
+
hls.destroy();
|
|
432
|
+
hls = null;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (error.fatal) {
|
|
436
|
+
api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
|
|
437
|
+
switch (error.type) {
|
|
438
|
+
case "network": {
|
|
439
|
+
const maxRetries = mergedConfig.maxNetworkRetries ?? 3;
|
|
440
|
+
if (networkRetryCount >= maxRetries) {
|
|
441
|
+
api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
|
|
442
|
+
emitFatalError(error, true);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
networkRetryCount++;
|
|
446
|
+
const delay = getRetryDelay(networkRetryCount - 1);
|
|
447
|
+
api?.logger.info(`Attempting network error recovery (attempt ${networkRetryCount}/${maxRetries}) in ${delay}ms`);
|
|
448
|
+
api?.emit("error:network", { error: new Error(error.details) });
|
|
449
|
+
if (retryTimeout) {
|
|
450
|
+
clearTimeout(retryTimeout);
|
|
451
|
+
}
|
|
452
|
+
retryTimeout = setTimeout(() => {
|
|
453
|
+
if (hls) {
|
|
454
|
+
hls.startLoad();
|
|
455
|
+
}
|
|
456
|
+
}, delay);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "media": {
|
|
460
|
+
const maxRetries = mergedConfig.maxMediaRetries ?? 2;
|
|
461
|
+
if (mediaRetryCount >= maxRetries) {
|
|
462
|
+
api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
|
|
463
|
+
emitFatalError(error, true);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
mediaRetryCount++;
|
|
467
|
+
const delay = getRetryDelay(mediaRetryCount - 1);
|
|
468
|
+
api?.logger.info(`Attempting media error recovery (attempt ${mediaRetryCount}/${maxRetries}) in ${delay}ms`);
|
|
469
|
+
api?.emit("error:media", { error: new Error(error.details) });
|
|
470
|
+
if (retryTimeout) {
|
|
471
|
+
clearTimeout(retryTimeout);
|
|
472
|
+
}
|
|
473
|
+
retryTimeout = setTimeout(() => {
|
|
474
|
+
if (hls) {
|
|
475
|
+
hls.recoverMediaError();
|
|
476
|
+
}
|
|
477
|
+
}, delay);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
default:
|
|
481
|
+
emitFatalError(error, false);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const loadNative = async (src) => {
|
|
487
|
+
const videoEl = getOrCreateVideo();
|
|
488
|
+
isNative = true;
|
|
489
|
+
if (api) {
|
|
490
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
491
|
+
}
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const onLoaded = () => {
|
|
494
|
+
videoEl.removeEventListener("loadedmetadata", onLoaded);
|
|
495
|
+
videoEl.removeEventListener("error", onError);
|
|
496
|
+
api?.setState("source", { src, type: "application/x-mpegURL" });
|
|
497
|
+
api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
|
|
498
|
+
resolve();
|
|
499
|
+
};
|
|
500
|
+
const onError = () => {
|
|
501
|
+
videoEl.removeEventListener("loadedmetadata", onLoaded);
|
|
502
|
+
videoEl.removeEventListener("error", onError);
|
|
503
|
+
const error = videoEl.error;
|
|
504
|
+
reject(new Error(error?.message || "Failed to load HLS source"));
|
|
505
|
+
};
|
|
506
|
+
videoEl.addEventListener("loadedmetadata", onLoaded);
|
|
507
|
+
videoEl.addEventListener("error", onError);
|
|
508
|
+
videoEl.src = src;
|
|
509
|
+
videoEl.load();
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
const loadWithHlsJs = async (src) => {
|
|
513
|
+
await loadHlsJs();
|
|
514
|
+
const videoEl = getOrCreateVideo();
|
|
515
|
+
isNative = false;
|
|
516
|
+
hls = createHlsInstance(buildHlsConfig());
|
|
517
|
+
if (api) {
|
|
518
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
519
|
+
}
|
|
520
|
+
return new Promise((resolve, reject) => {
|
|
521
|
+
if (!hls || !api) {
|
|
522
|
+
reject(new Error("HLS not initialized"));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
let resolved = false;
|
|
526
|
+
cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
|
|
527
|
+
onManifestParsed: () => {
|
|
528
|
+
if (!resolved) {
|
|
529
|
+
resolved = true;
|
|
530
|
+
api?.setState("source", { src, type: "application/x-mpegURL" });
|
|
531
|
+
api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
|
|
532
|
+
resolve();
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
onLevelSwitched: () => {
|
|
536
|
+
},
|
|
537
|
+
onError: (error) => {
|
|
538
|
+
handleHlsError(error);
|
|
539
|
+
if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
|
|
540
|
+
resolved = true;
|
|
541
|
+
reject(new Error(error.details));
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
getIsAutoQuality: () => isAutoQuality
|
|
545
|
+
});
|
|
546
|
+
hls.attachMedia(videoEl);
|
|
547
|
+
hls.loadSource(src);
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
const plugin = {
|
|
551
|
+
id: "hls-provider",
|
|
552
|
+
name: "HLS Provider",
|
|
553
|
+
version: "1.0.0",
|
|
554
|
+
type: "provider",
|
|
555
|
+
description: "HLS playback provider using hls.js",
|
|
556
|
+
canPlay(src) {
|
|
557
|
+
if (!isHLSSupported()) return false;
|
|
558
|
+
const url = src.toLowerCase();
|
|
559
|
+
const urlWithoutQuery = url.split("?")[0].split("#")[0];
|
|
560
|
+
if (urlWithoutQuery.endsWith(".m3u8")) return true;
|
|
561
|
+
if (url.includes("application/x-mpegurl")) return true;
|
|
562
|
+
if (url.includes("application/vnd.apple.mpegurl")) return true;
|
|
563
|
+
return false;
|
|
564
|
+
},
|
|
565
|
+
async init(pluginApi) {
|
|
566
|
+
api = pluginApi;
|
|
567
|
+
api.logger.info("HLS plugin initialized");
|
|
568
|
+
const unsubPlay = api.on("playback:play", async () => {
|
|
569
|
+
if (!video) return;
|
|
570
|
+
try {
|
|
571
|
+
await video.play();
|
|
572
|
+
} catch (e) {
|
|
573
|
+
api?.logger.error("Play failed", e);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
const unsubPause = api.on("playback:pause", () => {
|
|
577
|
+
video?.pause();
|
|
578
|
+
});
|
|
579
|
+
const unsubSeek = api.on("playback:seeking", ({ time }) => {
|
|
580
|
+
if (!video) return;
|
|
581
|
+
const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
|
|
582
|
+
video.currentTime = clampedTime;
|
|
583
|
+
});
|
|
584
|
+
const unsubVolume = api.on("volume:change", ({ volume }) => {
|
|
585
|
+
if (video) video.volume = volume;
|
|
586
|
+
});
|
|
587
|
+
const unsubMute = api.on("volume:mute", ({ muted }) => {
|
|
588
|
+
if (video) video.muted = muted;
|
|
589
|
+
});
|
|
590
|
+
const unsubRate = api.on("playback:ratechange", ({ rate }) => {
|
|
591
|
+
if (video) video.playbackRate = rate;
|
|
592
|
+
});
|
|
593
|
+
const unsubQuality = api.on("quality:select", ({ quality, auto }) => {
|
|
594
|
+
if (!hls || isNative) {
|
|
595
|
+
api?.logger.warn("Quality selection not available");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (auto || quality === "auto") {
|
|
599
|
+
isAutoQuality = true;
|
|
600
|
+
hls.currentLevel = -1;
|
|
601
|
+
api?.logger.debug("Quality: auto selection enabled");
|
|
602
|
+
api?.setState("currentQuality", {
|
|
603
|
+
id: "auto",
|
|
604
|
+
label: "Auto",
|
|
605
|
+
width: 0,
|
|
606
|
+
height: 0,
|
|
607
|
+
bitrate: 0,
|
|
608
|
+
active: true
|
|
609
|
+
});
|
|
610
|
+
} else {
|
|
611
|
+
isAutoQuality = false;
|
|
612
|
+
const levelIndex = parseInt(quality.replace("level-", ""), 10);
|
|
613
|
+
if (!isNaN(levelIndex) && levelIndex >= 0 && levelIndex < hls.levels.length) {
|
|
614
|
+
hls.currentLevel = levelIndex;
|
|
615
|
+
api?.logger.debug(`Quality: set to level ${levelIndex}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
api.onDestroy(() => {
|
|
620
|
+
unsubPlay();
|
|
621
|
+
unsubPause();
|
|
622
|
+
unsubSeek();
|
|
623
|
+
unsubVolume();
|
|
624
|
+
unsubMute();
|
|
625
|
+
unsubRate();
|
|
626
|
+
unsubQuality();
|
|
627
|
+
});
|
|
628
|
+
},
|
|
629
|
+
async destroy() {
|
|
630
|
+
api?.logger.info("HLS plugin destroying");
|
|
631
|
+
cleanup();
|
|
632
|
+
if (video?.parentNode) {
|
|
633
|
+
video.parentNode.removeChild(video);
|
|
634
|
+
}
|
|
635
|
+
video = null;
|
|
636
|
+
api = null;
|
|
637
|
+
},
|
|
638
|
+
async loadSource(src) {
|
|
639
|
+
if (!api) throw new Error("Plugin not initialized");
|
|
640
|
+
api.logger.info("Loading HLS source", { src });
|
|
641
|
+
cleanup();
|
|
642
|
+
currentSrc = src;
|
|
643
|
+
api.setState("playbackState", "loading");
|
|
644
|
+
api.setState("buffering", true);
|
|
645
|
+
if (isHlsJsSupported()) {
|
|
646
|
+
api.logger.info("Using hls.js for HLS playback");
|
|
647
|
+
await loadWithHlsJs(src);
|
|
648
|
+
} else if (supportsNativeHLS()) {
|
|
649
|
+
api.logger.info("Using native HLS playback (hls.js not supported)");
|
|
650
|
+
await loadNative(src);
|
|
651
|
+
} else {
|
|
652
|
+
throw new Error("HLS playback not supported in this browser");
|
|
653
|
+
}
|
|
654
|
+
api.setState("playbackState", "ready");
|
|
655
|
+
api.setState("buffering", false);
|
|
656
|
+
},
|
|
657
|
+
getCurrentLevel() {
|
|
658
|
+
if (isNative || !hls) return -1;
|
|
659
|
+
return hls.currentLevel;
|
|
660
|
+
},
|
|
661
|
+
setLevel(index) {
|
|
662
|
+
if (isNative || !hls) {
|
|
663
|
+
api?.logger.warn("Quality selection not available in native HLS mode");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
hls.currentLevel = index;
|
|
667
|
+
},
|
|
668
|
+
getLevels() {
|
|
669
|
+
if (isNative || !hls) return [];
|
|
670
|
+
return mapLevels(hls.levels, hls.currentLevel);
|
|
671
|
+
},
|
|
672
|
+
getHlsInstance() {
|
|
673
|
+
return hls;
|
|
674
|
+
},
|
|
675
|
+
isNativeHLS() {
|
|
676
|
+
return isNative;
|
|
677
|
+
},
|
|
678
|
+
getLiveInfo() {
|
|
679
|
+
if (isNative || !hls) return null;
|
|
680
|
+
const live = api?.getState("live") || false;
|
|
681
|
+
if (!live) return null;
|
|
682
|
+
return {
|
|
683
|
+
isLive: true,
|
|
684
|
+
latency: hls.latency || 0,
|
|
685
|
+
targetLatency: hls.targetLatency || 3,
|
|
686
|
+
drift: hls.drift || 0
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
/**
|
|
690
|
+
* Switch from hls.js to native HLS playback.
|
|
691
|
+
* Used for AirPlay compatibility in Safari.
|
|
692
|
+
* Preserves current playback position.
|
|
693
|
+
*/
|
|
694
|
+
async switchToNative() {
|
|
695
|
+
if (isNative) {
|
|
696
|
+
api?.logger.debug("Already using native HLS");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (!supportsNativeHLS()) {
|
|
700
|
+
api?.logger.warn("Native HLS not supported in this browser");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (!currentSrc) {
|
|
704
|
+
api?.logger.warn("No source loaded");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
api?.logger.info("Switching to native HLS for AirPlay");
|
|
708
|
+
const wasPlaying = api?.getState("playing") || false;
|
|
709
|
+
const currentTime = video?.currentTime || 0;
|
|
710
|
+
const savedSrc = currentSrc;
|
|
711
|
+
cleanup();
|
|
712
|
+
await loadNative(savedSrc);
|
|
713
|
+
if (video && currentTime > 0) {
|
|
714
|
+
video.currentTime = currentTime;
|
|
715
|
+
}
|
|
716
|
+
if (wasPlaying && video) {
|
|
717
|
+
try {
|
|
718
|
+
await video.play();
|
|
719
|
+
} catch (e) {
|
|
720
|
+
api?.logger.debug("Could not auto-resume after switch");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
api?.logger.info("Switched to native HLS");
|
|
724
|
+
},
|
|
725
|
+
/**
|
|
726
|
+
* Switch from native HLS back to hls.js.
|
|
727
|
+
* Restores quality control after AirPlay session ends.
|
|
728
|
+
*/
|
|
729
|
+
async switchToHlsJs() {
|
|
730
|
+
if (!isNative) {
|
|
731
|
+
api?.logger.debug("Already using hls.js");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (!isHlsJsSupported()) {
|
|
735
|
+
api?.logger.warn("hls.js not supported in this browser");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (!currentSrc) {
|
|
739
|
+
api?.logger.warn("No source loaded");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
api?.logger.info("Switching back to hls.js");
|
|
743
|
+
const wasPlaying = api?.getState("playing") || false;
|
|
744
|
+
const currentTime = video?.currentTime || 0;
|
|
745
|
+
const savedSrc = currentSrc;
|
|
746
|
+
cleanup();
|
|
747
|
+
await loadWithHlsJs(savedSrc);
|
|
748
|
+
if (video && currentTime > 0) {
|
|
749
|
+
video.currentTime = currentTime;
|
|
750
|
+
}
|
|
751
|
+
if (wasPlaying && video) {
|
|
752
|
+
try {
|
|
753
|
+
await video.play();
|
|
754
|
+
} catch (e) {
|
|
755
|
+
api?.logger.debug("Could not auto-resume after switch");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
api?.logger.info("Switched to hls.js");
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
return plugin;
|
|
762
|
+
}
|
|
763
|
+
var index_default = createHLSPlugin;
|
|
764
|
+
export {
|
|
765
|
+
createHLSPlugin,
|
|
766
|
+
index_default as default
|
|
767
|
+
};
|