@scarlett-player/embed 1.1.1 → 1.2.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.
@@ -1,530 +1,206 @@
1
- function formatLevel(level) {
2
- if (level.name) {
3
- return level.name;
1
+ class Signal {
2
+ constructor(initialValue) {
3
+ this.subscribers = /* @__PURE__ */ new Set();
4
+ this.value = initialValue;
4
5
  }
5
- if (level.height) {
6
- const standardLabels = {
7
- 2160: "4K",
8
- 1440: "1440p",
9
- 1080: "1080p",
10
- 720: "720p",
11
- 480: "480p",
12
- 360: "360p",
13
- 240: "240p",
14
- 144: "144p"
15
- };
16
- const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
17
- if (Math.abs(closest - level.height) <= 20) {
18
- return standardLabels[closest];
6
+ /**
7
+ * Get the current value and track dependency if called within an effect.
8
+ *
9
+ * @returns Current value
10
+ */
11
+ get() {
12
+ return this.value;
13
+ }
14
+ /**
15
+ * Set a new value and notify subscribers if changed.
16
+ *
17
+ * @param newValue - New value to set
18
+ */
19
+ set(newValue) {
20
+ if (Object.is(this.value, newValue)) {
21
+ return;
19
22
  }
20
- return `${level.height}p`;
23
+ this.value = newValue;
24
+ this.notify();
21
25
  }
22
- if (level.bitrate) {
23
- return formatBitrate(level.bitrate);
26
+ /**
27
+ * Update the value using a function.
28
+ *
29
+ * @param updater - Function that receives current value and returns new value
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const count = new Signal(0);
34
+ * count.update(n => n + 1); // Increments by 1
35
+ * ```
36
+ */
37
+ update(updater) {
38
+ this.set(updater(this.value));
24
39
  }
25
- return "Unknown";
26
- }
27
- function formatBitrate(bitrate) {
28
- if (bitrate >= 1e6) {
29
- return `${(bitrate / 1e6).toFixed(1)} Mbps`;
40
+ /**
41
+ * Subscribe to changes without automatic dependency tracking.
42
+ *
43
+ * @param callback - Function to call when value changes
44
+ * @returns Unsubscribe function
45
+ */
46
+ subscribe(callback) {
47
+ this.subscribers.add(callback);
48
+ return () => this.subscribers.delete(callback);
30
49
  }
31
- if (bitrate >= 1e3) {
32
- return `${Math.round(bitrate / 1e3)} Kbps`;
50
+ /**
51
+ * Notify all subscribers of a change.
52
+ * @internal
53
+ */
54
+ notify() {
55
+ this.subscribers.forEach((subscriber) => {
56
+ try {
57
+ subscriber();
58
+ } catch (error) {
59
+ console.error("[Scarlett Player] Error in signal subscriber:", error);
60
+ }
61
+ });
33
62
  }
34
- return `${bitrate} bps`;
35
- }
36
- function mapLevels(levels, _currentLevel) {
37
- return levels.map((level, index) => ({
38
- index,
39
- width: level.width || 0,
40
- height: level.height || 0,
41
- bitrate: level.bitrate || 0,
42
- label: formatLevel(level),
43
- codec: level.codecSet
44
- }));
45
- }
46
- function getInitialBandwidthEstimate(overrideBps) {
47
- const HLS_DEFAULT_ESTIMATE = 5e5;
48
- if (overrideBps !== void 0 && overrideBps > 0) {
49
- return overrideBps;
63
+ /**
64
+ * Clean up all subscriptions.
65
+ * Call this when destroying the signal.
66
+ */
67
+ destroy() {
68
+ this.subscribers.clear();
50
69
  }
51
- const connection = navigator.connection;
52
- if (connection?.downlink && connection.downlink > 0) {
53
- const bps = connection.downlink * 1e6;
54
- return Math.round(bps * 0.85);
70
+ /**
71
+ * Get the current number of subscribers (for debugging).
72
+ * @internal
73
+ */
74
+ getSubscriberCount() {
75
+ return this.subscribers.size;
55
76
  }
56
- return HLS_DEFAULT_ESTIMATE;
57
77
  }
58
- var HLS_ERROR_TYPES = {
59
- NETWORK_ERROR: "networkError",
60
- MEDIA_ERROR: "mediaError",
61
- MUX_ERROR: "muxError"
78
+ function signal(initialValue) {
79
+ return new Signal(initialValue);
80
+ }
81
+ const DEFAULT_STATE = {
82
+ // Core Playback State
83
+ playbackState: "idle",
84
+ playing: false,
85
+ paused: true,
86
+ ended: false,
87
+ buffering: false,
88
+ waiting: false,
89
+ seeking: false,
90
+ // Time & Duration
91
+ currentTime: 0,
92
+ duration: NaN,
93
+ buffered: null,
94
+ bufferedAmount: 0,
95
+ // Media Info
96
+ mediaType: "unknown",
97
+ source: null,
98
+ title: "",
99
+ poster: "",
100
+ // Volume & Audio
101
+ volume: 1,
102
+ muted: false,
103
+ // Playback Controls
104
+ playbackRate: 1,
105
+ fullscreen: false,
106
+ pip: false,
107
+ controlsVisible: true,
108
+ // Quality & Tracks
109
+ qualities: [],
110
+ currentQuality: null,
111
+ audioTracks: [],
112
+ currentAudioTrack: null,
113
+ textTracks: [],
114
+ currentTextTrack: null,
115
+ // Live/DVR State (TSP features)
116
+ live: false,
117
+ liveEdge: true,
118
+ seekableRange: null,
119
+ liveLatency: 0,
120
+ lowLatencyMode: false,
121
+ // Chapters (TSP features)
122
+ chapters: [],
123
+ currentChapter: null,
124
+ // Error State
125
+ error: null,
126
+ // Network & Performance
127
+ bandwidth: 0,
128
+ autoplay: false,
129
+ loop: false,
130
+ // Casting State
131
+ airplayAvailable: false,
132
+ airplayActive: false,
133
+ chromecastAvailable: false,
134
+ chromecastActive: false,
135
+ // Thumbnail Preview
136
+ thumbnails: null,
137
+ // UI State
138
+ interacting: false,
139
+ hovering: false,
140
+ focused: false
62
141
  };
63
- function mapErrorType(hlsType) {
64
- switch (hlsType) {
65
- case HLS_ERROR_TYPES.NETWORK_ERROR:
66
- return "network";
67
- case HLS_ERROR_TYPES.MEDIA_ERROR:
68
- return "media";
69
- case HLS_ERROR_TYPES.MUX_ERROR:
70
- return "mux";
71
- default:
72
- return "other";
142
+ class StateManager {
143
+ /**
144
+ * Create a new StateManager with default initial state.
145
+ *
146
+ * @param initialState - Optional partial initial state (merged with defaults)
147
+ */
148
+ constructor(initialState) {
149
+ this.signals = /* @__PURE__ */ new Map();
150
+ this.changeSubscribers = /* @__PURE__ */ new Set();
151
+ this.initializeSignals(initialState);
73
152
  }
74
- }
75
- function parseHlsError(data) {
76
- return {
77
- type: mapErrorType(data.type),
78
- details: data.details || "Unknown error",
79
- fatal: data.fatal || false,
80
- url: data.url,
81
- reason: data.reason,
82
- response: data.response
83
- };
84
- }
85
- function setupHlsEventHandlers(hls, api, callbacks) {
86
- const handlers = [];
87
- const addHandler = (event, handler) => {
88
- hls.on(event, handler);
89
- handlers.push({ event, handler });
90
- };
91
- addHandler("hlsManifestParsed", (_event, data) => {
92
- api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
93
- const levels = data.levels.map((level, index) => ({
94
- id: `level-${index}`,
95
- label: formatLevel(level),
96
- width: level.width,
97
- height: level.height,
98
- bitrate: level.bitrate,
99
- active: index === hls.currentLevel
100
- }));
101
- api.setState("qualities", levels);
102
- api.emit("quality:levels", {
103
- levels: levels.map((l) => ({ id: l.id, label: l.label }))
104
- });
105
- callbacks.onManifestParsed?.(data.levels);
106
- });
107
- addHandler("hlsLevelSwitched", (_event, data) => {
108
- const level = hls.levels[data.level];
109
- const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
110
- api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
111
- if (level) {
112
- const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
113
- api.setState("currentQuality", {
114
- id: isAuto ? "auto" : `level-${data.level}`,
115
- label,
116
- width: level.width,
117
- height: level.height,
118
- bitrate: level.bitrate,
119
- active: true
120
- });
121
- }
122
- api.emit("quality:change", {
123
- quality: level ? formatLevel(level) : "auto",
124
- auto: isAuto
125
- });
126
- callbacks.onLevelSwitched?.(data.level);
127
- });
128
- let lastBandwidthUpdate = 0;
129
- addHandler("hlsFragLoaded", () => {
130
- const now = Date.now();
131
- if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
132
- lastBandwidthUpdate = now;
133
- api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
134
- }
135
- callbacks.onFragLoaded?.();
136
- });
137
- addHandler("hlsFragBuffered", () => {
138
- api.setState("buffering", false);
139
- callbacks.onBufferUpdate?.();
140
- });
141
- addHandler("hlsFragLoading", () => {
142
- api.setState("buffering", true);
143
- });
144
- addHandler("hlsLevelLoaded", (_event, data) => {
145
- if (data.details?.live !== void 0) {
146
- api.setState("live", data.details.live);
147
- if (data.details.live) {
148
- const video = hls.media;
149
- if (video && video.seekable && video.seekable.length > 0) {
150
- const start = video.seekable.start(0);
151
- const end = video.seekable.end(video.seekable.length - 1);
152
- api.setState("seekableRange", { start, end });
153
- const threshold = (data.details.targetduration ?? 3) * 3;
154
- const isAtLiveEdge = end - video.currentTime < threshold;
155
- api.setState("liveEdge", isAtLiveEdge);
156
- const latency = end - video.currentTime;
157
- api.setState("liveLatency", Math.max(0, latency));
158
- }
159
- }
160
- callbacks.onLiveUpdate?.();
161
- }
162
- });
163
- addHandler("hlsError", (_event, data) => {
164
- const error = parseHlsError(data);
165
- const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
166
- if (isBufferHoleSeek) {
167
- api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
168
- details: error.details,
169
- reason: error.reason
170
- });
171
- } else if (error.fatal) {
172
- api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
173
- type: error.type,
174
- details: error.details,
175
- url: error.url
176
- });
177
- } else {
178
- api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
179
- type: error.type,
180
- details: error.details,
181
- fatal: error.fatal,
182
- url: error.url
183
- });
184
- }
185
- callbacks.onError?.(error);
186
- });
187
- return () => {
188
- for (const { event, handler } of handlers) {
189
- hls.off(event, handler);
190
- }
191
- handlers.length = 0;
192
- };
193
- }
194
- function setupVideoEventHandlers(video, api) {
195
- const handlers = [];
196
- const addHandler = (event, handler) => {
197
- video.addEventListener(event, handler);
198
- handlers.push({ event, handler });
199
- };
200
- addHandler("play", () => {
201
- api.setState("paused", false);
202
- });
203
- addHandler("playing", () => {
204
- api.setState("playing", true);
205
- api.setState("paused", false);
206
- api.setState("waiting", false);
207
- api.setState("buffering", false);
208
- api.setState("playbackState", "playing");
209
- });
210
- addHandler("pause", () => {
211
- api.setState("playing", false);
212
- api.setState("paused", true);
213
- api.setState("playbackState", "paused");
214
- });
215
- addHandler("ended", () => {
216
- api.setState("playing", false);
217
- api.setState("ended", true);
218
- api.setState("playbackState", "ended");
219
- api.emit("playback:ended", void 0);
220
- });
221
- addHandler("timeupdate", () => {
222
- api.setState("currentTime", video.currentTime);
223
- api.emit("playback:timeupdate", { currentTime: video.currentTime });
224
- const isLive = api.getState("live");
225
- if (isLive && video.seekable && video.seekable.length > 0) {
226
- const start = video.seekable.start(0);
227
- const end = video.seekable.end(video.seekable.length - 1);
228
- api.setState("seekableRange", { start, end });
229
- const isAtLiveEdge = end - video.currentTime < 10;
230
- api.setState("liveEdge", isAtLiveEdge);
231
- api.setState("liveLatency", Math.max(0, end - video.currentTime));
232
- }
233
- });
234
- addHandler("durationchange", () => {
235
- api.setState("duration", video.duration || 0);
236
- api.emit("media:loadedmetadata", { duration: video.duration || 0 });
237
- });
238
- addHandler("waiting", () => {
239
- api.setState("waiting", true);
240
- api.setState("buffering", true);
241
- api.emit("media:waiting", void 0);
242
- });
243
- addHandler("canplay", () => {
244
- api.setState("waiting", false);
245
- api.setState("playbackState", "ready");
246
- api.emit("media:canplay", void 0);
247
- });
248
- addHandler("canplaythrough", () => {
249
- api.setState("buffering", false);
250
- api.emit("media:canplaythrough", void 0);
251
- });
252
- addHandler("progress", () => {
253
- if (video.buffered.length > 0) {
254
- const bufferedEnd = video.buffered.end(video.buffered.length - 1);
255
- const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
256
- api.setState("bufferedAmount", bufferedAmount);
257
- api.setState("buffered", video.buffered);
258
- api.emit("media:progress", { buffered: bufferedAmount });
259
- }
260
- });
261
- addHandler("seeking", () => {
262
- api.setState("seeking", true);
263
- });
264
- addHandler("seeked", () => {
265
- api.setState("seeking", false);
266
- api.emit("playback:seeked", { time: video.currentTime });
267
- });
268
- addHandler("volumechange", () => {
269
- api.setState("volume", video.volume);
270
- api.setState("muted", video.muted);
271
- api.emit("volume:change", { volume: video.volume, muted: video.muted });
272
- });
273
- addHandler("ratechange", () => {
274
- api.setState("playbackRate", video.playbackRate);
275
- api.emit("playback:ratechange", { rate: video.playbackRate });
276
- });
277
- addHandler("loadedmetadata", () => {
278
- api.setState("duration", video.duration);
279
- api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
280
- });
281
- addHandler("loadeddata", () => {
282
- if (video.videoWidth > 0) {
283
- api.setState("mediaType", "video");
284
- }
285
- });
286
- addHandler("error", () => {
287
- const error = video.error;
288
- if (error) {
289
- api.logger.error("Video element error", { code: error.code, message: error.message });
290
- api.emit("media:error", { error: new Error(error.message || "Video playback error") });
291
- }
292
- });
293
- addHandler("enterpictureinpicture", () => {
294
- api.setState("pip", true);
295
- api.logger.debug("PiP: entered (standard)");
296
- });
297
- addHandler("leavepictureinpicture", () => {
298
- api.setState("pip", false);
299
- api.logger.debug("PiP: exited (standard)");
300
- if (!video.paused || api.getState("playing")) {
301
- video.play().catch(() => {
153
+ /**
154
+ * Initialize all state signals with default or provided values.
155
+ * @private
156
+ */
157
+ initializeSignals(overrides) {
158
+ const initialState = { ...DEFAULT_STATE, ...overrides };
159
+ for (const [key, value] of Object.entries(initialState)) {
160
+ const stateKey = key;
161
+ const stateSignal = signal(value);
162
+ stateSignal.subscribe(() => {
163
+ this.notifyChangeSubscribers(stateKey);
302
164
  });
165
+ this.signals.set(stateKey, stateSignal);
303
166
  }
304
- });
305
- const webkitVideo = video;
306
- if ("webkitPresentationMode" in video) {
307
- addHandler("webkitpresentationmodechanged", () => {
308
- const mode = webkitVideo.webkitPresentationMode;
309
- const isInPip = mode === "picture-in-picture";
310
- api.setState("pip", isInPip);
311
- api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
312
- if (mode === "inline" && video.paused) {
313
- video.play().catch(() => {
314
- });
315
- }
316
- });
317
- }
318
- return () => {
319
- for (const { event, handler } of handlers) {
320
- video.removeEventListener(event, handler);
321
- }
322
- handlers.length = 0;
323
- };
324
- }
325
- class Signal {
326
- constructor(initialValue) {
327
- this.subscribers = /* @__PURE__ */ new Set();
328
- this.value = initialValue;
329
167
  }
330
168
  /**
331
- * Get the current value and track dependency if called within an effect.
169
+ * Get the signal for a state property.
332
170
  *
333
- * @returns Current value
334
- */
335
- get() {
336
- return this.value;
337
- }
338
- /**
339
- * Set a new value and notify subscribers if changed.
171
+ * @param key - State property key
172
+ * @returns Signal for the property
340
173
  *
341
- * @param newValue - New value to set
174
+ * @example
175
+ * ```ts
176
+ * const playingSignal = state.get('playing');
177
+ * playingSignal.get(); // false
178
+ * playingSignal.set(true);
179
+ * ```
342
180
  */
343
- set(newValue) {
344
- if (Object.is(this.value, newValue)) {
345
- return;
181
+ get(key) {
182
+ const stateSignal = this.signals.get(key);
183
+ if (!stateSignal) {
184
+ throw new Error(`[StateManager] Unknown state key: ${key}`);
346
185
  }
347
- this.value = newValue;
348
- this.notify();
186
+ return stateSignal;
349
187
  }
350
188
  /**
351
- * Update the value using a function.
189
+ * Get the current value of a state property (convenience method).
352
190
  *
353
- * @param updater - Function that receives current value and returns new value
191
+ * @param key - State property key
192
+ * @returns Current value
354
193
  *
355
194
  * @example
356
195
  * ```ts
357
- * const count = new Signal(0);
358
- * count.update(n => n + 1); // Increments by 1
196
+ * state.getValue('playing'); // false
359
197
  * ```
360
198
  */
361
- update(updater) {
362
- this.set(updater(this.value));
199
+ getValue(key) {
200
+ return this.get(key).get();
363
201
  }
364
202
  /**
365
- * Subscribe to changes without automatic dependency tracking.
366
- *
367
- * @param callback - Function to call when value changes
368
- * @returns Unsubscribe function
369
- */
370
- subscribe(callback) {
371
- this.subscribers.add(callback);
372
- return () => this.subscribers.delete(callback);
373
- }
374
- /**
375
- * Notify all subscribers of a change.
376
- * @internal
377
- */
378
- notify() {
379
- this.subscribers.forEach((subscriber) => {
380
- try {
381
- subscriber();
382
- } catch (error) {
383
- console.error("[Scarlett Player] Error in signal subscriber:", error);
384
- }
385
- });
386
- }
387
- /**
388
- * Clean up all subscriptions.
389
- * Call this when destroying the signal.
390
- */
391
- destroy() {
392
- this.subscribers.clear();
393
- }
394
- /**
395
- * Get the current number of subscribers (for debugging).
396
- * @internal
397
- */
398
- getSubscriberCount() {
399
- return this.subscribers.size;
400
- }
401
- }
402
- function signal(initialValue) {
403
- return new Signal(initialValue);
404
- }
405
- const DEFAULT_STATE = {
406
- // Core Playback State
407
- playbackState: "idle",
408
- playing: false,
409
- paused: true,
410
- ended: false,
411
- buffering: false,
412
- waiting: false,
413
- seeking: false,
414
- // Time & Duration
415
- currentTime: 0,
416
- duration: NaN,
417
- buffered: null,
418
- bufferedAmount: 0,
419
- // Media Info
420
- mediaType: "unknown",
421
- source: null,
422
- title: "",
423
- poster: "",
424
- // Volume & Audio
425
- volume: 1,
426
- muted: false,
427
- // Playback Controls
428
- playbackRate: 1,
429
- fullscreen: false,
430
- pip: false,
431
- controlsVisible: true,
432
- // Quality & Tracks
433
- qualities: [],
434
- currentQuality: null,
435
- audioTracks: [],
436
- currentAudioTrack: null,
437
- textTracks: [],
438
- currentTextTrack: null,
439
- // Live/DVR State (TSP features)
440
- live: false,
441
- liveEdge: true,
442
- seekableRange: null,
443
- liveLatency: 0,
444
- lowLatencyMode: false,
445
- // Chapters (TSP features)
446
- chapters: [],
447
- currentChapter: null,
448
- // Error State
449
- error: null,
450
- // Network & Performance
451
- bandwidth: 0,
452
- autoplay: false,
453
- loop: false,
454
- // Casting State
455
- airplayAvailable: false,
456
- airplayActive: false,
457
- chromecastAvailable: false,
458
- chromecastActive: false,
459
- // Thumbnail Preview
460
- thumbnails: null,
461
- // UI State
462
- interacting: false,
463
- hovering: false,
464
- focused: false
465
- };
466
- class StateManager {
467
- /**
468
- * Create a new StateManager with default initial state.
469
- *
470
- * @param initialState - Optional partial initial state (merged with defaults)
471
- */
472
- constructor(initialState) {
473
- this.signals = /* @__PURE__ */ new Map();
474
- this.changeSubscribers = /* @__PURE__ */ new Set();
475
- this.initializeSignals(initialState);
476
- }
477
- /**
478
- * Initialize all state signals with default or provided values.
479
- * @private
480
- */
481
- initializeSignals(overrides) {
482
- const initialState = { ...DEFAULT_STATE, ...overrides };
483
- for (const [key, value] of Object.entries(initialState)) {
484
- const stateKey = key;
485
- const stateSignal = signal(value);
486
- stateSignal.subscribe(() => {
487
- this.notifyChangeSubscribers(stateKey);
488
- });
489
- this.signals.set(stateKey, stateSignal);
490
- }
491
- }
492
- /**
493
- * Get the signal for a state property.
494
- *
495
- * @param key - State property key
496
- * @returns Signal for the property
497
- *
498
- * @example
499
- * ```ts
500
- * const playingSignal = state.get('playing');
501
- * playingSignal.get(); // false
502
- * playingSignal.set(true);
503
- * ```
504
- */
505
- get(key) {
506
- const stateSignal = this.signals.get(key);
507
- if (!stateSignal) {
508
- throw new Error(`[StateManager] Unknown state key: ${key}`);
509
- }
510
- return stateSignal;
511
- }
512
- /**
513
- * Get the current value of a state property (convenience method).
514
- *
515
- * @param key - State property key
516
- * @returns Current value
517
- *
518
- * @example
519
- * ```ts
520
- * state.getValue('playing'); // false
521
- * ```
522
- */
523
- getValue(key) {
524
- return this.get(key).get();
525
- }
526
- /**
527
- * Set the value of a state property.
203
+ * Set the value of a state property.
528
204
  *
529
205
  * @param key - State property key
530
206
  * @param value - New value
@@ -1248,6 +924,9 @@ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
1248
924
  ErrorCode2["PLAYBACK_FAILED"] = "PLAYBACK_FAILED";
1249
925
  ErrorCode2["MEDIA_DECODE_ERROR"] = "MEDIA_DECODE_ERROR";
1250
926
  ErrorCode2["MEDIA_NETWORK_ERROR"] = "MEDIA_NETWORK_ERROR";
927
+ ErrorCode2["MEDIA_APPEND_ERROR"] = "MEDIA_APPEND_ERROR";
928
+ ErrorCode2["MEDIA_BUFFER_FULL"] = "MEDIA_BUFFER_FULL";
929
+ ErrorCode2["PLAYLIST_INVALID"] = "PLAYLIST_INVALID";
1251
930
  ErrorCode2["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
1252
931
  return ErrorCode2;
1253
932
  })(ErrorCode || {});
@@ -1290,6 +969,23 @@ class ErrorHandler {
1290
969
  this.eventBus.emit("error", playerError);
1291
970
  return playerError;
1292
971
  }
972
+ /**
973
+ * Record an error into history and logs WITHOUT emitting an `error` event.
974
+ *
975
+ * Used for advisory channels (e.g. media element errors that a provider's
976
+ * recovery path is already handling) that should be visible in
977
+ * getHistory() for diagnostics but must not flip the player's error state.
978
+ *
979
+ * @param error - Error to record (native or PlayerError)
980
+ * @param context - Optional context (what was happening)
981
+ * @returns Normalized PlayerError
982
+ */
983
+ record(error, context) {
984
+ const playerError = this.normalizeError(error, context);
985
+ this.addToHistory(playerError);
986
+ this.logError(playerError);
987
+ return playerError;
988
+ }
1293
989
  /**
1294
990
  * Create and handle an error from code.
1295
991
  *
@@ -1400,6 +1096,12 @@ class ErrorHandler {
1400
1096
  */
1401
1097
  getErrorCode(error) {
1402
1098
  const message = error.message.toLowerCase();
1099
+ if (message.includes("quota")) {
1100
+ return "MEDIA_BUFFER_FULL";
1101
+ }
1102
+ if (message.includes("append") || message.includes("sourcebuffer") || message.includes("arraybuffer")) {
1103
+ return "MEDIA_APPEND_ERROR";
1104
+ }
1403
1105
  if (message.includes("network")) {
1404
1106
  return "MEDIA_NETWORK_ERROR";
1405
1107
  }
@@ -1845,6 +1547,9 @@ class ScarlettPlayer {
1845
1547
  this.eventBus.on("media:loaded", () => {
1846
1548
  this.stateManager.set("error", null);
1847
1549
  });
1550
+ this.eventBus.on("media:error", ({ error }) => {
1551
+ this.errorHandler.record(error, { channel: "media:error" });
1552
+ });
1848
1553
  if (options.plugins) {
1849
1554
  for (const plugin of options.plugins) {
1850
1555
  this.pluginManager.register(plugin);
@@ -2473,105 +2178,426 @@ class ScarlettPlayer {
2473
2178
  if (this.destroyed) {
2474
2179
  throw new Error("Cannot call methods on destroyed player");
2475
2180
  }
2476
- }
2477
- /**
2478
- * Detect MIME type from source URL.
2479
- * @private
2480
- */
2481
- detectMimeType(source) {
2482
- let path = source;
2483
- try {
2484
- path = new URL(source).pathname;
2485
- } catch {
2486
- const noQuery = source.split("?")[0] ?? source;
2487
- path = noQuery.split("#")[0] ?? noQuery;
2181
+ }
2182
+ /**
2183
+ * Detect MIME type from source URL.
2184
+ * @private
2185
+ */
2186
+ detectMimeType(source) {
2187
+ let path = source;
2188
+ try {
2189
+ path = new URL(source).pathname;
2190
+ } catch {
2191
+ const noQuery = source.split("?")[0] ?? source;
2192
+ path = noQuery.split("#")[0] ?? noQuery;
2193
+ }
2194
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
2195
+ switch (ext) {
2196
+ case "m3u8":
2197
+ return "application/x-mpegURL";
2198
+ case "mpd":
2199
+ return "application/dash+xml";
2200
+ case "mp4":
2201
+ case "m4v":
2202
+ return "video/mp4";
2203
+ case "webm":
2204
+ return "video/webm";
2205
+ case "ogg":
2206
+ case "ogv":
2207
+ return "video/ogg";
2208
+ case "mov":
2209
+ return "video/quicktime";
2210
+ case "mkv":
2211
+ return "video/x-matroska";
2212
+ case "mp3":
2213
+ return "audio/mpeg";
2214
+ case "wav":
2215
+ return "audio/wav";
2216
+ case "flac":
2217
+ return "audio/flac";
2218
+ case "aac":
2219
+ case "m4a":
2220
+ return "audio/mp4";
2221
+ default:
2222
+ return "video/mp4";
2223
+ }
2224
+ }
2225
+ }
2226
+ async function createPlayer(options) {
2227
+ const player = new ScarlettPlayer(options);
2228
+ await player.init();
2229
+ return player;
2230
+ }
2231
+ var __defProp = Object.defineProperty;
2232
+ var __export = (target, all) => {
2233
+ for (var name in all)
2234
+ __defProp(target, name, { get: all[name], enumerable: true });
2235
+ };
2236
+ function formatLevel(level) {
2237
+ if (level.name) {
2238
+ return level.name;
2239
+ }
2240
+ if (level.height) {
2241
+ const standardLabels = {
2242
+ 2160: "4K",
2243
+ 1440: "1440p",
2244
+ 1080: "1080p",
2245
+ 720: "720p",
2246
+ 480: "480p",
2247
+ 360: "360p",
2248
+ 240: "240p",
2249
+ 144: "144p"
2250
+ };
2251
+ const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
2252
+ if (Math.abs(closest - level.height) <= 20) {
2253
+ return standardLabels[closest];
2254
+ }
2255
+ return `${level.height}p`;
2256
+ }
2257
+ if (level.bitrate) {
2258
+ return formatBitrate(level.bitrate);
2259
+ }
2260
+ return "Unknown";
2261
+ }
2262
+ function formatBitrate(bitrate) {
2263
+ if (bitrate >= 1e6) {
2264
+ return `${(bitrate / 1e6).toFixed(1)} Mbps`;
2265
+ }
2266
+ if (bitrate >= 1e3) {
2267
+ return `${Math.round(bitrate / 1e3)} Kbps`;
2268
+ }
2269
+ return `${bitrate} bps`;
2270
+ }
2271
+ function mapLevels(levels, _currentLevel) {
2272
+ return levels.map((level, index) => ({
2273
+ index,
2274
+ width: level.width || 0,
2275
+ height: level.height || 0,
2276
+ bitrate: level.bitrate || 0,
2277
+ label: formatLevel(level),
2278
+ codec: level.codecSet
2279
+ }));
2280
+ }
2281
+ function getInitialBandwidthEstimate(overrideBps) {
2282
+ const HLS_DEFAULT_ESTIMATE = 5e5;
2283
+ if (overrideBps !== void 0 && overrideBps > 0) {
2284
+ return overrideBps;
2285
+ }
2286
+ const connection = navigator.connection;
2287
+ if (connection?.downlink && connection.downlink > 0) {
2288
+ const bps = connection.downlink * 1e6;
2289
+ return Math.round(bps * 0.85);
2290
+ }
2291
+ return HLS_DEFAULT_ESTIMATE;
2292
+ }
2293
+ var HLS_ERROR_TYPES = {
2294
+ NETWORK_ERROR: "networkError",
2295
+ MEDIA_ERROR: "mediaError",
2296
+ MUX_ERROR: "muxError"
2297
+ };
2298
+ function mapErrorType(hlsType) {
2299
+ switch (hlsType) {
2300
+ case HLS_ERROR_TYPES.NETWORK_ERROR:
2301
+ return "network";
2302
+ case HLS_ERROR_TYPES.MEDIA_ERROR:
2303
+ return "media";
2304
+ case HLS_ERROR_TYPES.MUX_ERROR:
2305
+ return "mux";
2306
+ default:
2307
+ return "other";
2308
+ }
2309
+ }
2310
+ function parseHlsError(data) {
2311
+ return {
2312
+ type: mapErrorType(data.type),
2313
+ details: data.details || "Unknown error",
2314
+ fatal: data.fatal || false,
2315
+ url: data.url,
2316
+ reason: data.reason,
2317
+ response: data.response
2318
+ };
2319
+ }
2320
+ function setupHlsEventHandlers(hls, api, callbacks) {
2321
+ const handlers = [];
2322
+ const addHandler = (event, handler) => {
2323
+ hls.on(event, handler);
2324
+ handlers.push({ event, handler });
2325
+ };
2326
+ addHandler("hlsManifestParsed", (_event, data) => {
2327
+ api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
2328
+ const levels = data.levels.map((level, index) => ({
2329
+ id: `level-${index}`,
2330
+ label: formatLevel(level),
2331
+ width: level.width,
2332
+ height: level.height,
2333
+ bitrate: level.bitrate,
2334
+ active: index === hls.currentLevel
2335
+ }));
2336
+ api.setState("qualities", levels);
2337
+ api.emit("quality:levels", {
2338
+ levels: levels.map((l) => ({ id: l.id, label: l.label }))
2339
+ });
2340
+ callbacks.onManifestParsed?.(data.levels);
2341
+ });
2342
+ addHandler("hlsLevelSwitched", (_event, data) => {
2343
+ const level = hls.levels[data.level];
2344
+ const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
2345
+ api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
2346
+ if (level) {
2347
+ const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
2348
+ api.setState("currentQuality", {
2349
+ id: isAuto ? "auto" : `level-${data.level}`,
2350
+ label,
2351
+ width: level.width,
2352
+ height: level.height,
2353
+ bitrate: level.bitrate,
2354
+ active: true
2355
+ });
2356
+ }
2357
+ api.emit("quality:change", {
2358
+ quality: level ? formatLevel(level) : "auto",
2359
+ auto: isAuto
2360
+ });
2361
+ callbacks.onLevelSwitched?.(data.level);
2362
+ });
2363
+ let lastBandwidthUpdate = 0;
2364
+ addHandler("hlsFragLoaded", () => {
2365
+ const now = Date.now();
2366
+ if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
2367
+ lastBandwidthUpdate = now;
2368
+ api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
2369
+ }
2370
+ callbacks.onFragLoaded?.();
2371
+ });
2372
+ addHandler("hlsFragBuffered", () => {
2373
+ api.setState("buffering", false);
2374
+ callbacks.onBufferUpdate?.();
2375
+ });
2376
+ addHandler("hlsFragLoading", () => {
2377
+ api.setState("buffering", true);
2378
+ });
2379
+ addHandler("hlsLevelLoaded", (_event, data) => {
2380
+ if (data.details?.live !== void 0) {
2381
+ api.setState("live", data.details.live);
2382
+ if (data.details.live) {
2383
+ const video = hls.media;
2384
+ if (video && video.seekable && video.seekable.length > 0) {
2385
+ const start = video.seekable.start(0);
2386
+ const end = video.seekable.end(video.seekable.length - 1);
2387
+ api.setState("seekableRange", { start, end });
2388
+ const threshold = (data.details.targetduration ?? 3) * 3;
2389
+ const isAtLiveEdge = end - video.currentTime < threshold;
2390
+ api.setState("liveEdge", isAtLiveEdge);
2391
+ const latency = end - video.currentTime;
2392
+ api.setState("liveLatency", Math.max(0, latency));
2393
+ }
2394
+ }
2395
+ callbacks.onLiveUpdate?.();
2396
+ }
2397
+ });
2398
+ addHandler("hlsError", (_event, data) => {
2399
+ const error = parseHlsError(data);
2400
+ const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
2401
+ if (isBufferHoleSeek) {
2402
+ api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
2403
+ details: error.details,
2404
+ reason: error.reason
2405
+ });
2406
+ } else if (error.fatal) {
2407
+ api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
2408
+ type: error.type,
2409
+ details: error.details,
2410
+ url: error.url
2411
+ });
2412
+ } else {
2413
+ api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
2414
+ type: error.type,
2415
+ details: error.details,
2416
+ fatal: error.fatal,
2417
+ url: error.url
2418
+ });
2419
+ }
2420
+ callbacks.onError?.(error);
2421
+ });
2422
+ return () => {
2423
+ for (const { event, handler } of handlers) {
2424
+ hls.off(event, handler);
2425
+ }
2426
+ handlers.length = 0;
2427
+ };
2428
+ }
2429
+ function setupVideoEventHandlers(video, api) {
2430
+ const handlers = [];
2431
+ const addHandler = (event, handler) => {
2432
+ video.addEventListener(event, handler);
2433
+ handlers.push({ event, handler });
2434
+ };
2435
+ addHandler("play", () => {
2436
+ api.setState("paused", false);
2437
+ });
2438
+ addHandler("playing", () => {
2439
+ api.setState("playing", true);
2440
+ api.setState("paused", false);
2441
+ api.setState("waiting", false);
2442
+ api.setState("buffering", false);
2443
+ api.setState("playbackState", "playing");
2444
+ });
2445
+ addHandler("pause", () => {
2446
+ api.setState("playing", false);
2447
+ api.setState("paused", true);
2448
+ api.setState("playbackState", "paused");
2449
+ });
2450
+ addHandler("ended", () => {
2451
+ api.setState("playing", false);
2452
+ api.setState("ended", true);
2453
+ api.setState("playbackState", "ended");
2454
+ api.emit("playback:ended", void 0);
2455
+ });
2456
+ addHandler("timeupdate", () => {
2457
+ api.setState("currentTime", video.currentTime);
2458
+ api.emit("playback:timeupdate", { currentTime: video.currentTime });
2459
+ const isLive = api.getState("live");
2460
+ if (isLive && video.seekable && video.seekable.length > 0) {
2461
+ const start = video.seekable.start(0);
2462
+ const end = video.seekable.end(video.seekable.length - 1);
2463
+ api.setState("seekableRange", { start, end });
2464
+ const isAtLiveEdge = end - video.currentTime < 10;
2465
+ api.setState("liveEdge", isAtLiveEdge);
2466
+ api.setState("liveLatency", Math.max(0, end - video.currentTime));
2467
+ }
2468
+ });
2469
+ addHandler("durationchange", () => {
2470
+ api.setState("duration", video.duration || 0);
2471
+ api.emit("media:loadedmetadata", { duration: video.duration || 0 });
2472
+ });
2473
+ addHandler("waiting", () => {
2474
+ api.setState("waiting", true);
2475
+ api.setState("buffering", true);
2476
+ api.emit("media:waiting", void 0);
2477
+ });
2478
+ addHandler("canplay", () => {
2479
+ api.setState("waiting", false);
2480
+ api.setState("playbackState", "ready");
2481
+ api.emit("media:canplay", void 0);
2482
+ });
2483
+ addHandler("canplaythrough", () => {
2484
+ api.setState("buffering", false);
2485
+ api.emit("media:canplaythrough", void 0);
2486
+ });
2487
+ addHandler("progress", () => {
2488
+ if (video.buffered.length > 0) {
2489
+ const bufferedEnd = video.buffered.end(video.buffered.length - 1);
2490
+ const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
2491
+ api.setState("bufferedAmount", bufferedAmount);
2492
+ api.setState("buffered", video.buffered);
2493
+ api.emit("media:progress", { buffered: bufferedAmount });
2494
+ }
2495
+ });
2496
+ addHandler("seeking", () => {
2497
+ api.setState("seeking", true);
2498
+ });
2499
+ addHandler("seeked", () => {
2500
+ api.setState("seeking", false);
2501
+ api.emit("playback:seeked", { time: video.currentTime });
2502
+ });
2503
+ addHandler("volumechange", () => {
2504
+ api.setState("volume", video.volume);
2505
+ api.setState("muted", video.muted);
2506
+ api.emit("volume:change", { volume: video.volume, muted: video.muted });
2507
+ });
2508
+ addHandler("ratechange", () => {
2509
+ api.setState("playbackRate", video.playbackRate);
2510
+ api.emit("playback:ratechange", { rate: video.playbackRate });
2511
+ });
2512
+ addHandler("loadedmetadata", () => {
2513
+ api.setState("duration", video.duration);
2514
+ api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
2515
+ });
2516
+ addHandler("loadeddata", () => {
2517
+ if (video.videoWidth > 0) {
2518
+ api.setState("mediaType", "video");
2488
2519
  }
2489
- const ext = path.split(".").pop()?.toLowerCase() ?? "";
2490
- switch (ext) {
2491
- case "m3u8":
2492
- return "application/x-mpegURL";
2493
- case "mpd":
2494
- return "application/dash+xml";
2495
- case "mp4":
2496
- case "m4v":
2497
- return "video/mp4";
2498
- case "webm":
2499
- return "video/webm";
2500
- case "ogg":
2501
- case "ogv":
2502
- return "video/ogg";
2503
- case "mov":
2504
- return "video/quicktime";
2505
- case "mkv":
2506
- return "video/x-matroska";
2507
- case "mp3":
2508
- return "audio/mpeg";
2509
- case "wav":
2510
- return "audio/wav";
2511
- case "flac":
2512
- return "audio/flac";
2513
- case "aac":
2514
- case "m4a":
2515
- return "audio/mp4";
2516
- default:
2517
- return "video/mp4";
2520
+ });
2521
+ addHandler("error", () => {
2522
+ const error = video.error;
2523
+ if (error) {
2524
+ api.logger.error("Video element error", { code: error.code, message: error.message });
2525
+ api.emit("media:error", { error: new Error(error.message || "Video playback error") });
2518
2526
  }
2519
- }
2520
- }
2521
- async function createPlayer(options) {
2522
- const player = new ScarlettPlayer(options);
2523
- await player.init();
2524
- return player;
2525
- }
2526
- var hlsConstructor = null;
2527
- var loadingPromise = null;
2528
- function supportsNativeHLS() {
2529
- if (typeof document === "undefined") return false;
2530
- const video = document.createElement("video");
2531
- return video.canPlayType("application/vnd.apple.mpegurl") !== "";
2532
- }
2533
- function isHlsJsSupported() {
2534
- if (hlsConstructor) {
2535
- return hlsConstructor.isSupported();
2536
- }
2537
- if (typeof window === "undefined") return false;
2538
- return !!(window.MediaSource || window.WebKitMediaSource);
2539
- }
2540
- function isHLSSupported() {
2541
- return supportsNativeHLS() || isHlsJsSupported();
2542
- }
2543
- async function loadHlsJs() {
2544
- if (hlsConstructor) {
2545
- return hlsConstructor;
2546
- }
2547
- if (loadingPromise) {
2548
- return loadingPromise;
2549
- }
2550
- loadingPromise = (async () => {
2551
- try {
2552
- const hlsModule = await import("./hls.js");
2553
- hlsConstructor = hlsModule.default;
2554
- if (!hlsConstructor.isSupported()) {
2555
- throw new Error("hls.js is not supported in this browser");
2527
+ });
2528
+ addHandler("enterpictureinpicture", () => {
2529
+ api.setState("pip", true);
2530
+ api.logger.debug("PiP: entered (standard)");
2531
+ });
2532
+ addHandler("leavepictureinpicture", () => {
2533
+ api.setState("pip", false);
2534
+ api.logger.debug("PiP: exited (standard)");
2535
+ if (!video.paused || api.getState("playing")) {
2536
+ video.play().catch(() => {
2537
+ });
2538
+ }
2539
+ });
2540
+ const webkitVideo = video;
2541
+ if ("webkitPresentationMode" in video) {
2542
+ addHandler("webkitpresentationmodechanged", () => {
2543
+ const mode = webkitVideo.webkitPresentationMode;
2544
+ const isInPip = mode === "picture-in-picture";
2545
+ api.setState("pip", isInPip);
2546
+ api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
2547
+ if (mode === "inline" && video.paused) {
2548
+ video.play().catch(() => {
2549
+ });
2556
2550
  }
2557
- return hlsConstructor;
2558
- } catch (error) {
2559
- loadingPromise = null;
2560
- throw new Error(
2561
- `Failed to load hls.js: ${error instanceof Error ? error.message : "Unknown error"}`
2562
- );
2551
+ });
2552
+ }
2553
+ return () => {
2554
+ for (const { event, handler } of handlers) {
2555
+ video.removeEventListener(event, handler);
2563
2556
  }
2564
- })();
2565
- return loadingPromise;
2557
+ handlers.length = 0;
2558
+ };
2566
2559
  }
2567
- function createHlsInstance(config) {
2568
- if (!hlsConstructor) {
2569
- throw new Error("hls.js is not loaded. Call loadHlsJs() first.");
2570
- }
2571
- return new hlsConstructor(config);
2560
+ var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
2561
+ var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
2562
+ function isValidPlaylistDocument(data, contextType) {
2563
+ if (typeof data !== "string" || data.length === 0) return false;
2564
+ const text = data.trimStart();
2565
+ if (!text.startsWith("#EXTM3U")) return false;
2566
+ if (contextType && MEDIA_PLAYLIST_CONTEXTS.includes(contextType)) {
2567
+ return /^#EXT(?:INF|-X-TARGETDURATION):/m.test(text);
2568
+ }
2569
+ return true;
2572
2570
  }
2573
- function getHlsConstructor() {
2574
- return hlsConstructor;
2571
+ function createValidatingPlaylistLoader(Hls) {
2572
+ const BaseLoader = Hls.DefaultConfig.loader;
2573
+ return class ValidatingPlaylistLoader extends BaseLoader {
2574
+ /**
2575
+ * Load a playlist, validating the response document before it reaches
2576
+ * the M3U8 parser.
2577
+ *
2578
+ * @param context - hls.js loader context
2579
+ * @param config - hls.js loader config
2580
+ * @param callbacks - hls.js loader callbacks
2581
+ */
2582
+ load(context, config, callbacks) {
2583
+ const wrapped = {
2584
+ ...callbacks,
2585
+ onSuccess: (response, stats, ctx, networkDetails) => {
2586
+ if (!isValidPlaylistDocument(response?.data, ctx?.type)) {
2587
+ callbacks.onError(
2588
+ { code: 0, text: PLAYLIST_INVALID_TEXT },
2589
+ ctx,
2590
+ networkDetails,
2591
+ stats
2592
+ );
2593
+ return;
2594
+ }
2595
+ callbacks.onSuccess(response, stats, ctx, networkDetails);
2596
+ }
2597
+ };
2598
+ super.load(context, config, wrapped);
2599
+ }
2600
+ };
2575
2601
  }
2576
2602
  var DEFAULT_CONFIG$3 = {
2577
2603
  debug: false,
@@ -2594,14 +2620,16 @@ var DEFAULT_CONFIG$3 = {
2594
2620
  autoReconnect: true,
2595
2621
  reconnectBaseDelayMs: 2e3,
2596
2622
  reconnectMaxDelayMs: 3e4,
2597
- reconnectWindowMs: 3e5
2623
+ reconnectWindowMs: 3e5,
2624
+ // Never index a malformed live playlist refresh blindly
2625
+ validatePlaylists: true
2598
2626
  };
2599
2627
  var MANIFEST_PHASE_ERRORS = [
2600
2628
  "manifestLoadError",
2601
2629
  "manifestLoadTimeOut",
2602
2630
  "manifestParsingError"
2603
2631
  ];
2604
- function createHLSPlugin(config) {
2632
+ function createHLSPluginWith(loader, variant, config) {
2605
2633
  const mergedConfig = { ...DEFAULT_CONFIG$3, ...config };
2606
2634
  let api = null;
2607
2635
  let hls = null;
@@ -2611,6 +2639,8 @@ function createHLSPlugin(config) {
2611
2639
  let cleanupHlsEvents = null;
2612
2640
  let cleanupVideoEvents = null;
2613
2641
  let isAutoQuality = true;
2642
+ let loadSession = 0;
2643
+ let abortPendingLoad = null;
2614
2644
  let networkRetryCount = 0;
2615
2645
  let mediaRetryCount = 0;
2616
2646
  let retryTimeout = null;
@@ -2643,7 +2673,9 @@ function createHLSPlugin(config) {
2643
2673
  api?.container.appendChild(video);
2644
2674
  return video;
2645
2675
  };
2646
- const cleanup = () => {
2676
+ const teardownPipeline = (reason) => {
2677
+ abortPendingLoad?.(reason ?? new Error("HLS load cancelled"));
2678
+ abortPendingLoad = null;
2647
2679
  cleanupHlsEvents?.();
2648
2680
  cleanupHlsEvents = null;
2649
2681
  cleanupVideoEvents?.();
@@ -2656,6 +2688,9 @@ function createHLSPlugin(config) {
2656
2688
  hls.destroy();
2657
2689
  hls = null;
2658
2690
  }
2691
+ };
2692
+ const cleanup = (reason) => {
2693
+ teardownPipeline(reason);
2659
2694
  currentSrc = null;
2660
2695
  isNative = false;
2661
2696
  isAutoQuality = true;
@@ -2664,7 +2699,17 @@ function createHLSPlugin(config) {
2664
2699
  errorCount = 0;
2665
2700
  errorWindowStart = 0;
2666
2701
  };
2667
- const buildHlsConfig = () => ({
2702
+ const buildHlsConfig = () => {
2703
+ const config2 = buildBaseHlsConfig();
2704
+ if (mergedConfig.validatePlaylists !== false) {
2705
+ const Hls = loader.getHlsConstructor();
2706
+ if (Hls && Hls.DefaultConfig?.loader) {
2707
+ config2.pLoader = createValidatingPlaylistLoader(Hls);
2708
+ }
2709
+ }
2710
+ return config2;
2711
+ };
2712
+ const buildBaseHlsConfig = () => ({
2668
2713
  debug: mergedConfig.debug,
2669
2714
  autoStartLoad: mergedConfig.autoStartLoad,
2670
2715
  startPosition: mergedConfig.startPosition,
@@ -2692,7 +2737,21 @@ function createHLSPlugin(config) {
2692
2737
  const jitter = delay * (0.7 + Math.random() * 0.3);
2693
2738
  return jitter;
2694
2739
  };
2740
+ const APPEND_ERROR_DETAILS = [
2741
+ "bufferAppendError",
2742
+ "bufferAppendingError",
2743
+ "bufferAddCodecError"
2744
+ ];
2695
2745
  const mapFatalErrorCode = (error) => {
2746
+ if (error.response?.text === PLAYLIST_INVALID_TEXT) {
2747
+ return ErrorCode.PLAYLIST_INVALID;
2748
+ }
2749
+ if (error.details === "bufferFullError") {
2750
+ return ErrorCode.MEDIA_BUFFER_FULL;
2751
+ }
2752
+ if (APPEND_ERROR_DETAILS.includes(error.details)) {
2753
+ return ErrorCode.MEDIA_APPEND_ERROR;
2754
+ }
2696
2755
  switch (error.type) {
2697
2756
  case "network":
2698
2757
  return ErrorCode.MEDIA_NETWORK_ERROR;
@@ -2717,7 +2776,7 @@ function createHLSPlugin(config) {
2717
2776
  maybeScheduleReconnect(error);
2718
2777
  };
2719
2778
  const handleHlsError = (error) => {
2720
- const Hls = getHlsConstructor();
2779
+ const Hls = loader.getHlsConstructor();
2721
2780
  if (!Hls || !hls) return false;
2722
2781
  const now = Date.now();
2723
2782
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
@@ -2729,10 +2788,7 @@ function createHLSPlugin(config) {
2729
2788
  if (errorCount >= MAX_ERRORS_IN_WINDOW) {
2730
2789
  api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
2731
2790
  emitFatalError(error, true);
2732
- cleanupHlsEvents?.();
2733
- cleanupHlsEvents = null;
2734
- hls.destroy();
2735
- hls = null;
2791
+ teardownPipeline(new Error(error.details));
2736
2792
  return true;
2737
2793
  }
2738
2794
  if (error.fatal) {
@@ -2753,8 +2809,9 @@ function createHLSPlugin(config) {
2753
2809
  clearTimeout(retryTimeout);
2754
2810
  }
2755
2811
  const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
2812
+ const retry_session = loadSession;
2756
2813
  retryTimeout = setTimeout(() => {
2757
- if (!hls) return;
2814
+ if (retry_session !== loadSession || !hls) return;
2758
2815
  if (isManifestPhase && currentSrc) {
2759
2816
  hls.loadSource(currentSrc);
2760
2817
  } else {
@@ -2777,10 +2834,10 @@ function createHLSPlugin(config) {
2777
2834
  if (retryTimeout) {
2778
2835
  clearTimeout(retryTimeout);
2779
2836
  }
2837
+ const retry_session = loadSession;
2780
2838
  retryTimeout = setTimeout(() => {
2781
- if (hls) {
2782
- hls.recoverMediaError();
2783
- }
2839
+ if (retry_session !== loadSession || !hls) return;
2840
+ hls.recoverMediaError();
2784
2841
  }, delay);
2785
2842
  break;
2786
2843
  }
@@ -2792,6 +2849,7 @@ function createHLSPlugin(config) {
2792
2849
  return false;
2793
2850
  };
2794
2851
  const loadNative = async (src) => {
2852
+ const session = loadSession;
2795
2853
  const videoEl = getOrCreateVideo();
2796
2854
  isNative = true;
2797
2855
  if (api) {
@@ -2799,7 +2857,12 @@ function createHLSPlugin(config) {
2799
2857
  }
2800
2858
  return new Promise((resolve, reject) => {
2801
2859
  let watchdog = null;
2860
+ let settled = false;
2802
2861
  const settle = () => {
2862
+ settled = true;
2863
+ if (abortPendingLoad === abort) {
2864
+ abortPendingLoad = null;
2865
+ }
2803
2866
  videoEl.removeEventListener("loadedmetadata", onLoaded);
2804
2867
  videoEl.removeEventListener("error", onError);
2805
2868
  if (watchdog !== null) {
@@ -2807,7 +2870,19 @@ function createHLSPlugin(config) {
2807
2870
  watchdog = null;
2808
2871
  }
2809
2872
  };
2873
+ const abort = (reason) => {
2874
+ if (settled) return;
2875
+ settle();
2876
+ reject(reason);
2877
+ };
2878
+ abortPendingLoad = abort;
2810
2879
  const onLoaded = () => {
2880
+ if (settled) return;
2881
+ if (session !== loadSession) {
2882
+ settle();
2883
+ reject(new Error("HLS load cancelled"));
2884
+ return;
2885
+ }
2811
2886
  settle();
2812
2887
  hasPlayedContent = true;
2813
2888
  const onFatalVideoError = () => {
@@ -2830,6 +2905,7 @@ function createHLSPlugin(config) {
2830
2905
  resolve();
2831
2906
  };
2832
2907
  const onError = () => {
2908
+ if (settled) return;
2833
2909
  settle();
2834
2910
  const error = videoEl.error;
2835
2911
  reject(new Error(error?.message || "Failed to load HLS source"));
@@ -2837,6 +2913,7 @@ function createHLSPlugin(config) {
2837
2913
  const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
2838
2914
  if (timeout_ms > 0) {
2839
2915
  watchdog = setTimeout(() => {
2916
+ if (settled || session !== loadSession) return;
2840
2917
  settle();
2841
2918
  reject(new Error("Video took too long to load (network timeout)"));
2842
2919
  }, timeout_ms);
@@ -2848,10 +2925,14 @@ function createHLSPlugin(config) {
2848
2925
  });
2849
2926
  };
2850
2927
  const loadWithHlsJs = async (src) => {
2851
- await loadHlsJs();
2928
+ const session = loadSession;
2929
+ await loader.loadHlsJs();
2930
+ if (session !== loadSession) {
2931
+ throw new Error("HLS load cancelled");
2932
+ }
2852
2933
  const videoEl = getOrCreateVideo();
2853
2934
  isNative = false;
2854
- hls = createHlsInstance(buildHlsConfig());
2935
+ hls = loader.createHlsInstance(buildHlsConfig());
2855
2936
  if (api) {
2856
2937
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
2857
2938
  }
@@ -2868,10 +2949,24 @@ function createHLSPlugin(config) {
2868
2949
  watchdog = null;
2869
2950
  }
2870
2951
  };
2952
+ const abort = (reason) => {
2953
+ if (resolved) return;
2954
+ resolved = true;
2955
+ clearWatchdog();
2956
+ reject(reason);
2957
+ };
2958
+ abortPendingLoad = abort;
2959
+ const releaseAbort = () => {
2960
+ if (abortPendingLoad === abort) {
2961
+ abortPendingLoad = null;
2962
+ }
2963
+ };
2871
2964
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
2872
2965
  onManifestParsed: () => {
2966
+ if (session !== loadSession) return;
2873
2967
  if (!resolved) {
2874
2968
  resolved = true;
2969
+ releaseAbort();
2875
2970
  clearWatchdog();
2876
2971
  hasPlayedContent = true;
2877
2972
  api?.setState("source", { src, type: "application/x-mpegURL" });
@@ -2882,14 +2977,17 @@ function createHLSPlugin(config) {
2882
2977
  onLevelSwitched: () => {
2883
2978
  },
2884
2979
  onError: (error) => {
2980
+ if (session !== loadSession) return;
2885
2981
  const terminal = handleHlsError(error);
2886
2982
  if (terminal && !resolved) {
2887
2983
  resolved = true;
2984
+ releaseAbort();
2888
2985
  clearWatchdog();
2889
2986
  reject(new Error(error.details));
2890
2987
  }
2891
2988
  },
2892
2989
  onFragLoaded: () => {
2990
+ if (session !== loadSession) return;
2893
2991
  if (networkRetryCount > 0 || mediaRetryCount > 0) {
2894
2992
  api?.logger.debug("Playback recovered, resetting retry budgets");
2895
2993
  networkRetryCount = 0;
@@ -2901,17 +2999,11 @@ function createHLSPlugin(config) {
2901
2999
  const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
2902
3000
  if (timeout_ms > 0) {
2903
3001
  watchdog = setTimeout(() => {
2904
- if (resolved) return;
3002
+ if (resolved || session !== loadSession) return;
2905
3003
  resolved = true;
3004
+ releaseAbort();
2906
3005
  api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
2907
- if (retryTimeout) {
2908
- clearTimeout(retryTimeout);
2909
- retryTimeout = null;
2910
- }
2911
- cleanupHlsEvents?.();
2912
- cleanupHlsEvents = null;
2913
- hls?.destroy();
2914
- hls = null;
3006
+ teardownPipeline();
2915
3007
  reject(new Error("Video took too long to load (network timeout)"));
2916
3008
  }, timeout_ms);
2917
3009
  }
@@ -2958,6 +3050,7 @@ function createHLSPlugin(config) {
2958
3050
  };
2959
3051
  const attemptReconnect = async () => {
2960
3052
  if (!api || !currentSrc) return;
3053
+ const session = ++loadSession;
2961
3054
  reconnectAttempts++;
2962
3055
  const saved_src = currentSrc;
2963
3056
  const was_live = api.getState("live");
@@ -2965,27 +3058,19 @@ function createHLSPlugin(config) {
2965
3058
  const resume_position = reconnectResumePosition;
2966
3059
  api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
2967
3060
  try {
2968
- cleanupHlsEvents?.();
2969
- cleanupHlsEvents = null;
2970
- cleanupVideoEvents?.();
2971
- cleanupVideoEvents = null;
2972
- if (retryTimeout) {
2973
- clearTimeout(retryTimeout);
2974
- retryTimeout = null;
2975
- }
2976
- hls?.destroy();
2977
- hls = null;
3061
+ teardownPipeline(new Error("HLS load cancelled: reconnecting"));
2978
3062
  networkRetryCount = 0;
2979
3063
  mediaRetryCount = 0;
2980
3064
  errorCount = 0;
2981
3065
  errorWindowStart = 0;
2982
3066
  currentSrc = saved_src;
2983
3067
  api.setState("playbackState", "loading");
2984
- if (was_native && supportsNativeHLS()) {
3068
+ if (was_native && loader.supportsNativeHLS()) {
2985
3069
  await loadNative(saved_src);
2986
3070
  } else {
2987
3071
  await loadWithHlsJs(saved_src);
2988
3072
  }
3073
+ if (session !== loadSession) return;
2989
3074
  if (!was_live && video && resume_position > 0) {
2990
3075
  video.currentTime = resume_position;
2991
3076
  }
@@ -2999,18 +3084,19 @@ function createHLSPlugin(config) {
2999
3084
  } catch {
3000
3085
  }
3001
3086
  } catch {
3087
+ if (session !== loadSession) return;
3002
3088
  api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
3003
3089
  scheduleReconnectAttempt();
3004
3090
  }
3005
3091
  };
3006
3092
  const plugin = {
3007
3093
  id: "hls-provider",
3008
- name: "HLS Provider",
3094
+ name: variant.name,
3009
3095
  version: "1.0.0",
3010
3096
  type: "provider",
3011
- description: "HLS playback provider using hls.js",
3097
+ description: variant.description,
3012
3098
  canPlay(src) {
3013
- if (!isHLSSupported()) return false;
3099
+ if (!loader.isHLSSupported()) return false;
3014
3100
  const url = src.toLowerCase();
3015
3101
  const urlWithoutQuery = url.split("?")[0].split("#")[0];
3016
3102
  if (urlWithoutQuery.endsWith(".m3u8")) return true;
@@ -3020,7 +3106,7 @@ function createHLSPlugin(config) {
3020
3106
  },
3021
3107
  async init(pluginApi) {
3022
3108
  api = pluginApi;
3023
- api.logger.info("HLS plugin initialized");
3109
+ api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
3024
3110
  const unsubPlay = api.on("playback:play", async () => {
3025
3111
  if (!video) return;
3026
3112
  try {
@@ -3108,13 +3194,14 @@ function createHLSPlugin(config) {
3108
3194
  });
3109
3195
  },
3110
3196
  async destroy() {
3111
- api?.logger.info("HLS plugin destroying");
3197
+ api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
3198
+ loadSession++;
3112
3199
  cancelReconnect();
3113
3200
  if (onlineListener && typeof window !== "undefined") {
3114
3201
  window.removeEventListener("online", onlineListener);
3115
3202
  onlineListener = null;
3116
3203
  }
3117
- cleanup();
3204
+ cleanup(new Error("HLS load cancelled: player destroyed"));
3118
3205
  if (video?.parentNode) {
3119
3206
  video.parentNode.removeChild(video);
3120
3207
  }
@@ -3123,25 +3210,27 @@ function createHLSPlugin(config) {
3123
3210
  },
3124
3211
  async loadSource(src) {
3125
3212
  if (!api) throw new Error("Plugin not initialized");
3126
- api.logger.info("Loading HLS source", { src });
3213
+ api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
3214
+ const session = ++loadSession;
3127
3215
  cancelReconnect();
3128
3216
  hasPlayedContent = false;
3129
- cleanup();
3217
+ cleanup(new Error("HLS load cancelled: superseded by a new load"));
3130
3218
  currentSrc = src;
3131
3219
  api.setState("playbackState", "loading");
3132
3220
  api.setState("buffering", true);
3133
- if (api.getState("airplayActive") && supportsNativeHLS()) {
3221
+ if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
3134
3222
  api.logger.info("Using native HLS (AirPlay active)");
3135
3223
  await loadNative(src);
3136
- } else if (isHlsJsSupported()) {
3137
- api.logger.info("Using hls.js for HLS playback");
3224
+ } else if (loader.isHlsJsSupported()) {
3225
+ api.logger.info(`Using ${variant.engineLabel} for HLS playback`);
3138
3226
  await loadWithHlsJs(src);
3139
- } else if (supportsNativeHLS()) {
3227
+ } else if (loader.supportsNativeHLS()) {
3140
3228
  api.logger.info("Using native HLS playback (hls.js not supported)");
3141
3229
  await loadNative(src);
3142
3230
  } else {
3143
3231
  throw new Error("HLS playback not supported in this browser");
3144
3232
  }
3233
+ if (session !== loadSession) return;
3145
3234
  if (video) {
3146
3235
  const muted = api.getState("muted");
3147
3236
  const volume = api.getState("volume");
@@ -3193,7 +3282,7 @@ function createHLSPlugin(config) {
3193
3282
  api?.logger.debug("Already using native HLS");
3194
3283
  return;
3195
3284
  }
3196
- if (!supportsNativeHLS()) {
3285
+ if (!loader.supportsNativeHLS()) {
3197
3286
  api?.logger.warn("Native HLS not supported in this browser");
3198
3287
  return;
3199
3288
  }
@@ -3205,8 +3294,10 @@ function createHLSPlugin(config) {
3205
3294
  const wasPlaying = api?.getState("playing") || false;
3206
3295
  const currentTime = video?.currentTime || 0;
3207
3296
  const savedSrc = currentSrc;
3208
- cleanup();
3297
+ const session = ++loadSession;
3298
+ cleanup(new Error("HLS load cancelled: switching to native HLS"));
3209
3299
  await loadNative(savedSrc);
3300
+ if (session !== loadSession) return;
3210
3301
  if (video && currentTime > 0) {
3211
3302
  video.currentTime = currentTime;
3212
3303
  }
@@ -3228,7 +3319,7 @@ function createHLSPlugin(config) {
3228
3319
  api?.logger.debug("Already using hls.js");
3229
3320
  return;
3230
3321
  }
3231
- if (!isHlsJsSupported()) {
3322
+ if (!loader.isHlsJsSupported()) {
3232
3323
  api?.logger.warn("hls.js not supported in this browser");
3233
3324
  return;
3234
3325
  }
@@ -3240,8 +3331,10 @@ function createHLSPlugin(config) {
3240
3331
  const wasPlaying = api?.getState("playing") || false;
3241
3332
  const currentTime = video?.currentTime || 0;
3242
3333
  const savedSrc = currentSrc;
3243
- cleanup();
3334
+ const session = ++loadSession;
3335
+ cleanup(new Error("HLS load cancelled: switching to hls.js"));
3244
3336
  await loadWithHlsJs(savedSrc);
3337
+ if (session !== loadSession) return;
3245
3338
  if (video && currentTime > 0) {
3246
3339
  video.currentTime = currentTime;
3247
3340
  }
@@ -3257,6 +3350,90 @@ function createHLSPlugin(config) {
3257
3350
  };
3258
3351
  return plugin;
3259
3352
  }
3353
+ var hls_loader_exports = {};
3354
+ __export(hls_loader_exports, {
3355
+ createHlsInstance: () => createHlsInstance,
3356
+ getHlsConstructor: () => getHlsConstructor,
3357
+ isHLSSupported: () => isHLSSupported,
3358
+ isHlsJsSupported: () => isHlsJsSupported,
3359
+ loadHlsJs: () => loadHlsJs,
3360
+ resetLoader: () => resetLoader,
3361
+ shouldPreferNativeHLS: () => shouldPreferNativeHLS,
3362
+ supportsNativeHLS: () => supportsNativeHLS
3363
+ });
3364
+ var hlsConstructor = null;
3365
+ var loadingPromise = null;
3366
+ function supportsNativeHLS() {
3367
+ if (typeof document === "undefined") return false;
3368
+ const video = document.createElement("video");
3369
+ return video.canPlayType("application/vnd.apple.mpegurl") !== "";
3370
+ }
3371
+ function shouldPreferNativeHLS() {
3372
+ if (!supportsNativeHLS()) return false;
3373
+ if (typeof navigator === "undefined") return false;
3374
+ const ua = navigator.userAgent;
3375
+ const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/CriOS/.test(ua);
3376
+ return isSafari;
3377
+ }
3378
+ function isHlsJsSupported() {
3379
+ if (hlsConstructor) {
3380
+ return hlsConstructor.isSupported();
3381
+ }
3382
+ if (typeof window === "undefined") return false;
3383
+ return !!(window.MediaSource || window.WebKitMediaSource);
3384
+ }
3385
+ function isHLSSupported() {
3386
+ return supportsNativeHLS() || isHlsJsSupported();
3387
+ }
3388
+ async function loadHlsJs() {
3389
+ if (hlsConstructor) {
3390
+ return hlsConstructor;
3391
+ }
3392
+ if (loadingPromise) {
3393
+ return loadingPromise;
3394
+ }
3395
+ loadingPromise = (async () => {
3396
+ try {
3397
+ const hlsModule = await import("./hls.js");
3398
+ hlsConstructor = hlsModule.default;
3399
+ if (!hlsConstructor.isSupported()) {
3400
+ throw new Error("hls.js is not supported in this browser");
3401
+ }
3402
+ return hlsConstructor;
3403
+ } catch (error) {
3404
+ loadingPromise = null;
3405
+ throw new Error(
3406
+ `Failed to load hls.js: ${error instanceof Error ? error.message : "Unknown error"}`
3407
+ );
3408
+ }
3409
+ })();
3410
+ return loadingPromise;
3411
+ }
3412
+ function createHlsInstance(config) {
3413
+ if (!hlsConstructor) {
3414
+ throw new Error("hls.js is not loaded. Call loadHlsJs() first.");
3415
+ }
3416
+ return new hlsConstructor(config);
3417
+ }
3418
+ function getHlsConstructor() {
3419
+ return hlsConstructor;
3420
+ }
3421
+ function resetLoader() {
3422
+ hlsConstructor = null;
3423
+ loadingPromise = null;
3424
+ }
3425
+ function createHLSPlugin(config) {
3426
+ return createHLSPluginWith(
3427
+ hls_loader_exports,
3428
+ {
3429
+ name: "HLS Provider",
3430
+ description: "HLS playback provider using hls.js",
3431
+ logSuffix: "",
3432
+ engineLabel: "hls.js"
3433
+ },
3434
+ config
3435
+ );
3436
+ }
3260
3437
  var DEFAULT_THEME = {
3261
3438
  primary: "#6366f1",
3262
3439
  background: "#18181b",