@scarlett-player/embed 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/dist/embed.js CHANGED
@@ -1,516 +1,231 @@
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.definedDefaults = /* @__PURE__ */ new Map();
152
+ this.initializeSignals(initialState);
73
153
  }
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 }))
154
+ /**
155
+ * Initialize all state signals with default or provided values.
156
+ * @private
157
+ */
158
+ initializeSignals(overrides) {
159
+ const initialState = { ...DEFAULT_STATE, ...overrides };
160
+ for (const [key, value] of Object.entries(initialState)) {
161
+ this.createSignal(key, value);
162
+ }
163
+ }
164
+ /**
165
+ * Create and register a signal, wired to the global change subscribers.
166
+ *
167
+ * Shared by initializeSignals() and define() so a plugin-defined key behaves
168
+ * exactly like a built-in one and the two paths cannot drift apart.
169
+ *
170
+ * @private
171
+ */
172
+ createSignal(key, value) {
173
+ const stateSignal = signal(value);
174
+ stateSignal.subscribe(() => {
175
+ this.notifyChangeSubscribers(key);
104
176
  });
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(() => {
302
- });
303
- }
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;
177
+ this.signals.set(key, stateSignal);
329
178
  }
330
179
  /**
331
- * Get the current value and track dependency if called within an effect.
180
+ * Register a state key at runtime, for state a plugin owns.
332
181
  *
333
- * @returns Current value
334
- */
335
- get() {
336
- return this.value;
337
- }
338
- /**
339
- * Set a new value and notify subscribers if changed.
182
+ * Core cannot know every plugin's keys, and {@link get} deliberately throws
183
+ * for unregistered ones — that throw is a useful typo-catcher and is worth
184
+ * keeping — so a plugin declares its keys before first use.
340
185
  *
341
- * @param newValue - New value to set
186
+ * Idempotent by design: re-defining an existing key leaves the current value
187
+ * untouched. Plugins commonly re-run setup after a source change, and that
188
+ * must not reset state that is already live.
189
+ *
190
+ * Namespace plugin keys with the plugin's own name to avoid collisions.
191
+ *
192
+ * @param key - State property key
193
+ * @param initialValue - Value used only when the key is new
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * state.define('highlightSelection', null);
198
+ * ```
342
199
  */
343
- set(newValue) {
344
- if (Object.is(this.value, newValue)) {
200
+ define(key, initialValue) {
201
+ if (this.signals.has(key)) {
345
202
  return;
346
203
  }
347
- this.value = newValue;
348
- this.notify();
204
+ this.definedDefaults.set(key, initialValue);
205
+ this.createSignal(key, initialValue);
349
206
  }
350
207
  /**
351
- * Update the value using a function.
208
+ * Get the signal for a state property.
352
209
  *
353
- * @param updater - Function that receives current value and returns new value
210
+ * @param key - State property key
211
+ * @returns Signal for the property
354
212
  *
355
213
  * @example
356
214
  * ```ts
357
- * const count = new Signal(0);
358
- * count.update(n => n + 1); // Increments by 1
215
+ * const playingSignal = state.get('playing');
216
+ * playingSignal.get(); // false
217
+ * playingSignal.set(true);
359
218
  * ```
360
219
  */
361
- update(updater) {
362
- this.set(updater(this.value));
220
+ get(key) {
221
+ const stateSignal = this.signals.get(key);
222
+ if (!stateSignal) {
223
+ throw new Error(`[StateManager] Unknown state key: ${key}`);
224
+ }
225
+ return stateSignal;
363
226
  }
364
227
  /**
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).
228
+ * Get the current value of a state property (convenience method).
514
229
  *
515
230
  * @param key - State property key
516
231
  * @returns Current value
@@ -631,7 +346,10 @@ class StateManager {
631
346
  * ```
632
347
  */
633
348
  reset() {
634
- this.update(DEFAULT_STATE);
349
+ this.update({
350
+ ...DEFAULT_STATE,
351
+ ...Object.fromEntries(this.definedDefaults)
352
+ });
635
353
  }
636
354
  /**
637
355
  * Reset a specific state property to its default value.
@@ -644,7 +362,7 @@ class StateManager {
644
362
  * ```
645
363
  */
646
364
  resetKey(key) {
647
- const defaultValue = DEFAULT_STATE[key];
365
+ const defaultValue = key in DEFAULT_STATE ? DEFAULT_STATE[key] : this.definedDefaults.get(key);
648
366
  this.set(key, defaultValue);
649
367
  }
650
368
  /**
@@ -1248,6 +966,9 @@ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
1248
966
  ErrorCode2["PLAYBACK_FAILED"] = "PLAYBACK_FAILED";
1249
967
  ErrorCode2["MEDIA_DECODE_ERROR"] = "MEDIA_DECODE_ERROR";
1250
968
  ErrorCode2["MEDIA_NETWORK_ERROR"] = "MEDIA_NETWORK_ERROR";
969
+ ErrorCode2["MEDIA_APPEND_ERROR"] = "MEDIA_APPEND_ERROR";
970
+ ErrorCode2["MEDIA_BUFFER_FULL"] = "MEDIA_BUFFER_FULL";
971
+ ErrorCode2["PLAYLIST_INVALID"] = "PLAYLIST_INVALID";
1251
972
  ErrorCode2["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
1252
973
  return ErrorCode2;
1253
974
  })(ErrorCode || {});
@@ -1290,6 +1011,23 @@ class ErrorHandler {
1290
1011
  this.eventBus.emit("error", playerError);
1291
1012
  return playerError;
1292
1013
  }
1014
+ /**
1015
+ * Record an error into history and logs WITHOUT emitting an `error` event.
1016
+ *
1017
+ * Used for advisory channels (e.g. media element errors that a provider's
1018
+ * recovery path is already handling) that should be visible in
1019
+ * getHistory() for diagnostics but must not flip the player's error state.
1020
+ *
1021
+ * @param error - Error to record (native or PlayerError)
1022
+ * @param context - Optional context (what was happening)
1023
+ * @returns Normalized PlayerError
1024
+ */
1025
+ record(error, context) {
1026
+ const playerError = this.normalizeError(error, context);
1027
+ this.addToHistory(playerError);
1028
+ this.logError(playerError);
1029
+ return playerError;
1030
+ }
1293
1031
  /**
1294
1032
  * Create and handle an error from code.
1295
1033
  *
@@ -1400,6 +1138,12 @@ class ErrorHandler {
1400
1138
  */
1401
1139
  getErrorCode(error) {
1402
1140
  const message = error.message.toLowerCase();
1141
+ if (message.includes("quota")) {
1142
+ return "MEDIA_BUFFER_FULL";
1143
+ }
1144
+ if (message.includes("append") || message.includes("sourcebuffer") || message.includes("arraybuffer")) {
1145
+ return "MEDIA_APPEND_ERROR";
1146
+ }
1403
1147
  if (message.includes("network")) {
1404
1148
  return "MEDIA_NETWORK_ERROR";
1405
1149
  }
@@ -1512,6 +1256,18 @@ class PluginAPI {
1512
1256
  setState(key, value) {
1513
1257
  this.stateManager.set(key, value);
1514
1258
  }
1259
+ /**
1260
+ * Register a state key this plugin owns, before first use.
1261
+ *
1262
+ * Idempotent — re-defining an existing key keeps its current value.
1263
+ * See {@link IPluginAPI.defineState}.
1264
+ *
1265
+ * @param key - State property key
1266
+ * @param initialValue - Value used only when the key is new
1267
+ */
1268
+ defineState(key, initialValue) {
1269
+ this.stateManager.define(key, initialValue);
1270
+ }
1515
1271
  /**
1516
1272
  * Subscribe to an event.
1517
1273
  *
@@ -1845,6 +1601,9 @@ class ScarlettPlayer {
1845
1601
  this.eventBus.on("media:loaded", () => {
1846
1602
  this.stateManager.set("error", null);
1847
1603
  });
1604
+ this.eventBus.on("media:error", ({ error }) => {
1605
+ this.errorHandler.record(error, { channel: "media:error" });
1606
+ });
1848
1607
  if (options.plugins) {
1849
1608
  for (const plugin of options.plugins) {
1850
1609
  this.pluginManager.register(plugin);
@@ -2516,62 +2275,383 @@ class ScarlettPlayer {
2516
2275
  default:
2517
2276
  return "video/mp4";
2518
2277
  }
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");
2278
+ }
2279
+ }
2280
+ async function createPlayer(options) {
2281
+ const player = new ScarlettPlayer(options);
2282
+ await player.init();
2283
+ return player;
2284
+ }
2285
+ var __defProp = Object.defineProperty;
2286
+ var __export = (target, all) => {
2287
+ for (var name in all)
2288
+ __defProp(target, name, { get: all[name], enumerable: true });
2289
+ };
2290
+ function formatLevel(level) {
2291
+ if (level.name) {
2292
+ return level.name;
2293
+ }
2294
+ if (level.height) {
2295
+ const standardLabels = {
2296
+ 2160: "4K",
2297
+ 1440: "1440p",
2298
+ 1080: "1080p",
2299
+ 720: "720p",
2300
+ 480: "480p",
2301
+ 360: "360p",
2302
+ 240: "240p",
2303
+ 144: "144p"
2304
+ };
2305
+ const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
2306
+ if (Math.abs(closest - level.height) <= 20) {
2307
+ return standardLabels[closest];
2308
+ }
2309
+ return `${level.height}p`;
2310
+ }
2311
+ if (level.bitrate) {
2312
+ return formatBitrate(level.bitrate);
2313
+ }
2314
+ return "Unknown";
2315
+ }
2316
+ function formatBitrate(bitrate) {
2317
+ if (bitrate >= 1e6) {
2318
+ return `${(bitrate / 1e6).toFixed(1)} Mbps`;
2319
+ }
2320
+ if (bitrate >= 1e3) {
2321
+ return `${Math.round(bitrate / 1e3)} Kbps`;
2322
+ }
2323
+ return `${bitrate} bps`;
2324
+ }
2325
+ function mapLevels(levels, _currentLevel) {
2326
+ return levels.map((level, index) => ({
2327
+ index,
2328
+ width: level.width || 0,
2329
+ height: level.height || 0,
2330
+ bitrate: level.bitrate || 0,
2331
+ label: formatLevel(level),
2332
+ codec: level.codecSet
2333
+ }));
2334
+ }
2335
+ function getInitialBandwidthEstimate(overrideBps) {
2336
+ const HLS_DEFAULT_ESTIMATE = 5e5;
2337
+ if (overrideBps !== void 0 && overrideBps > 0) {
2338
+ return overrideBps;
2339
+ }
2340
+ const connection = navigator.connection;
2341
+ if (connection?.downlink && connection.downlink > 0) {
2342
+ const bps = connection.downlink * 1e6;
2343
+ return Math.round(bps * 0.85);
2344
+ }
2345
+ return HLS_DEFAULT_ESTIMATE;
2346
+ }
2347
+ var HLS_ERROR_TYPES = {
2348
+ NETWORK_ERROR: "networkError",
2349
+ MEDIA_ERROR: "mediaError",
2350
+ MUX_ERROR: "muxError"
2351
+ };
2352
+ function mapErrorType(hlsType) {
2353
+ switch (hlsType) {
2354
+ case HLS_ERROR_TYPES.NETWORK_ERROR:
2355
+ return "network";
2356
+ case HLS_ERROR_TYPES.MEDIA_ERROR:
2357
+ return "media";
2358
+ case HLS_ERROR_TYPES.MUX_ERROR:
2359
+ return "mux";
2360
+ default:
2361
+ return "other";
2362
+ }
2363
+ }
2364
+ function parseHlsError(data) {
2365
+ return {
2366
+ type: mapErrorType(data.type),
2367
+ details: data.details || "Unknown error",
2368
+ fatal: data.fatal || false,
2369
+ url: data.url,
2370
+ reason: data.reason,
2371
+ response: data.response
2372
+ };
2373
+ }
2374
+ function setupHlsEventHandlers(hls, api, callbacks) {
2375
+ const handlers = [];
2376
+ const addHandler = (event, handler) => {
2377
+ hls.on(event, handler);
2378
+ handlers.push({ event, handler });
2379
+ };
2380
+ addHandler("hlsManifestParsed", (_event, data) => {
2381
+ api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
2382
+ const levels = data.levels.map((level, index) => ({
2383
+ id: `level-${index}`,
2384
+ label: formatLevel(level),
2385
+ width: level.width,
2386
+ height: level.height,
2387
+ bitrate: level.bitrate,
2388
+ active: index === hls.currentLevel
2389
+ }));
2390
+ api.setState("qualities", levels);
2391
+ api.emit("quality:levels", {
2392
+ levels: levels.map((l) => ({ id: l.id, label: l.label }))
2393
+ });
2394
+ callbacks.onManifestParsed?.(data.levels);
2395
+ });
2396
+ addHandler("hlsLevelSwitched", (_event, data) => {
2397
+ const level = hls.levels[data.level];
2398
+ const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
2399
+ api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
2400
+ if (level) {
2401
+ const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
2402
+ api.setState("currentQuality", {
2403
+ id: isAuto ? "auto" : `level-${data.level}`,
2404
+ label,
2405
+ width: level.width,
2406
+ height: level.height,
2407
+ bitrate: level.bitrate,
2408
+ active: true
2409
+ });
2410
+ }
2411
+ api.emit("quality:change", {
2412
+ quality: level ? formatLevel(level) : "auto",
2413
+ auto: isAuto
2414
+ });
2415
+ callbacks.onLevelSwitched?.(data.level);
2416
+ });
2417
+ let lastBandwidthUpdate = 0;
2418
+ addHandler("hlsFragLoaded", () => {
2419
+ const now = Date.now();
2420
+ if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
2421
+ lastBandwidthUpdate = now;
2422
+ api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
2423
+ }
2424
+ callbacks.onFragLoaded?.();
2425
+ });
2426
+ addHandler("hlsFragBuffered", () => {
2427
+ api.setState("buffering", false);
2428
+ callbacks.onBufferUpdate?.();
2429
+ });
2430
+ addHandler("hlsFragLoading", () => {
2431
+ api.setState("buffering", true);
2432
+ });
2433
+ addHandler("hlsLevelLoaded", (_event, data) => {
2434
+ if (data.details?.live !== void 0) {
2435
+ api.setState("live", data.details.live);
2436
+ if (data.details.live) {
2437
+ const video = hls.media;
2438
+ if (video && video.seekable && video.seekable.length > 0) {
2439
+ const start = video.seekable.start(0);
2440
+ const end = video.seekable.end(video.seekable.length - 1);
2441
+ api.setState("seekableRange", { start, end });
2442
+ const threshold = (data.details.targetduration ?? 3) * 3;
2443
+ const isAtLiveEdge = end - video.currentTime < threshold;
2444
+ api.setState("liveEdge", isAtLiveEdge);
2445
+ const latency = end - video.currentTime;
2446
+ api.setState("liveLatency", Math.max(0, latency));
2447
+ }
2448
+ }
2449
+ callbacks.onLiveUpdate?.();
2450
+ }
2451
+ });
2452
+ addHandler("hlsError", (_event, data) => {
2453
+ const error = parseHlsError(data);
2454
+ const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
2455
+ if (isBufferHoleSeek) {
2456
+ api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
2457
+ details: error.details,
2458
+ reason: error.reason
2459
+ });
2460
+ } else if (error.fatal) {
2461
+ api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
2462
+ type: error.type,
2463
+ details: error.details,
2464
+ url: error.url
2465
+ });
2466
+ } else {
2467
+ api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
2468
+ type: error.type,
2469
+ details: error.details,
2470
+ fatal: error.fatal,
2471
+ url: error.url
2472
+ });
2473
+ }
2474
+ callbacks.onError?.(error);
2475
+ });
2476
+ return () => {
2477
+ for (const { event, handler } of handlers) {
2478
+ hls.off(event, handler);
2479
+ }
2480
+ handlers.length = 0;
2481
+ };
2482
+ }
2483
+ function setupVideoEventHandlers(video, api) {
2484
+ const handlers = [];
2485
+ const addHandler = (event, handler) => {
2486
+ video.addEventListener(event, handler);
2487
+ handlers.push({ event, handler });
2488
+ };
2489
+ addHandler("play", () => {
2490
+ api.setState("paused", false);
2491
+ });
2492
+ addHandler("playing", () => {
2493
+ api.setState("playing", true);
2494
+ api.setState("paused", false);
2495
+ api.setState("waiting", false);
2496
+ api.setState("buffering", false);
2497
+ api.setState("playbackState", "playing");
2498
+ });
2499
+ addHandler("pause", () => {
2500
+ api.setState("playing", false);
2501
+ api.setState("paused", true);
2502
+ api.setState("playbackState", "paused");
2503
+ });
2504
+ addHandler("ended", () => {
2505
+ api.setState("playing", false);
2506
+ api.setState("ended", true);
2507
+ api.setState("playbackState", "ended");
2508
+ api.emit("playback:ended", void 0);
2509
+ });
2510
+ addHandler("timeupdate", () => {
2511
+ api.setState("currentTime", video.currentTime);
2512
+ api.emit("playback:timeupdate", { currentTime: video.currentTime });
2513
+ const isLive = api.getState("live");
2514
+ if (isLive && video.seekable && video.seekable.length > 0) {
2515
+ const start = video.seekable.start(0);
2516
+ const end = video.seekable.end(video.seekable.length - 1);
2517
+ api.setState("seekableRange", { start, end });
2518
+ const isAtLiveEdge = end - video.currentTime < 10;
2519
+ api.setState("liveEdge", isAtLiveEdge);
2520
+ api.setState("liveLatency", Math.max(0, end - video.currentTime));
2521
+ }
2522
+ });
2523
+ addHandler("durationchange", () => {
2524
+ api.setState("duration", video.duration || 0);
2525
+ api.emit("media:loadedmetadata", { duration: video.duration || 0 });
2526
+ });
2527
+ addHandler("waiting", () => {
2528
+ api.setState("waiting", true);
2529
+ api.setState("buffering", true);
2530
+ api.emit("media:waiting", void 0);
2531
+ });
2532
+ addHandler("canplay", () => {
2533
+ api.setState("waiting", false);
2534
+ api.setState("playbackState", "ready");
2535
+ api.emit("media:canplay", void 0);
2536
+ });
2537
+ addHandler("canplaythrough", () => {
2538
+ api.setState("buffering", false);
2539
+ api.emit("media:canplaythrough", void 0);
2540
+ });
2541
+ addHandler("progress", () => {
2542
+ if (video.buffered.length > 0) {
2543
+ const bufferedEnd = video.buffered.end(video.buffered.length - 1);
2544
+ const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
2545
+ api.setState("bufferedAmount", bufferedAmount);
2546
+ api.setState("buffered", video.buffered);
2547
+ api.emit("media:progress", { buffered: bufferedAmount });
2548
+ }
2549
+ });
2550
+ addHandler("seeking", () => {
2551
+ api.setState("seeking", true);
2552
+ });
2553
+ addHandler("seeked", () => {
2554
+ api.setState("seeking", false);
2555
+ api.emit("playback:seeked", { time: video.currentTime });
2556
+ });
2557
+ addHandler("volumechange", () => {
2558
+ api.setState("volume", video.volume);
2559
+ api.setState("muted", video.muted);
2560
+ api.emit("volume:change", { volume: video.volume, muted: video.muted });
2561
+ });
2562
+ addHandler("ratechange", () => {
2563
+ api.setState("playbackRate", video.playbackRate);
2564
+ api.emit("playback:ratechange", { rate: video.playbackRate });
2565
+ });
2566
+ addHandler("loadedmetadata", () => {
2567
+ api.setState("duration", video.duration);
2568
+ api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
2569
+ });
2570
+ addHandler("loadeddata", () => {
2571
+ if (video.videoWidth > 0) {
2572
+ api.setState("mediaType", "video");
2573
+ }
2574
+ });
2575
+ addHandler("error", () => {
2576
+ const error = video.error;
2577
+ if (error) {
2578
+ api.logger.error("Video element error", { code: error.code, message: error.message });
2579
+ api.emit("media:error", { error: new Error(error.message || "Video playback error") });
2580
+ }
2581
+ });
2582
+ addHandler("enterpictureinpicture", () => {
2583
+ api.setState("pip", true);
2584
+ api.logger.debug("PiP: entered (standard)");
2585
+ });
2586
+ addHandler("leavepictureinpicture", () => {
2587
+ api.setState("pip", false);
2588
+ api.logger.debug("PiP: exited (standard)");
2589
+ if (!video.paused || api.getState("playing")) {
2590
+ video.play().catch(() => {
2591
+ });
2592
+ }
2593
+ });
2594
+ const webkitVideo = video;
2595
+ if ("webkitPresentationMode" in video) {
2596
+ addHandler("webkitpresentationmodechanged", () => {
2597
+ const mode = webkitVideo.webkitPresentationMode;
2598
+ const isInPip = mode === "picture-in-picture";
2599
+ api.setState("pip", isInPip);
2600
+ api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
2601
+ if (mode === "inline" && video.paused) {
2602
+ video.play().catch(() => {
2603
+ });
2556
2604
  }
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
- );
2605
+ });
2606
+ }
2607
+ return () => {
2608
+ for (const { event, handler } of handlers) {
2609
+ video.removeEventListener(event, handler);
2563
2610
  }
2564
- })();
2565
- return loadingPromise;
2611
+ handlers.length = 0;
2612
+ };
2566
2613
  }
2567
- function createHlsInstance(config) {
2568
- if (!hlsConstructor) {
2569
- throw new Error("hls.js is not loaded. Call loadHlsJs() first.");
2614
+ var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
2615
+ var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
2616
+ function isValidPlaylistDocument(data, contextType) {
2617
+ if (typeof data !== "string" || data.length === 0) return false;
2618
+ const text = data.trimStart();
2619
+ if (!text.startsWith("#EXTM3U")) return false;
2620
+ if (contextType && MEDIA_PLAYLIST_CONTEXTS.includes(contextType)) {
2621
+ return /^#EXT(?:INF|-X-TARGETDURATION):/m.test(text);
2570
2622
  }
2571
- return new hlsConstructor(config);
2623
+ return true;
2572
2624
  }
2573
- function getHlsConstructor() {
2574
- return hlsConstructor;
2625
+ function createValidatingPlaylistLoader(Hls) {
2626
+ const BaseLoader = Hls.DefaultConfig.loader;
2627
+ return class ValidatingPlaylistLoader extends BaseLoader {
2628
+ /**
2629
+ * Load a playlist, validating the response document before it reaches
2630
+ * the M3U8 parser.
2631
+ *
2632
+ * @param context - hls.js loader context
2633
+ * @param config - hls.js loader config
2634
+ * @param callbacks - hls.js loader callbacks
2635
+ */
2636
+ load(context, config, callbacks) {
2637
+ const wrapped = {
2638
+ ...callbacks,
2639
+ onSuccess: (response, stats, ctx, networkDetails) => {
2640
+ if (!isValidPlaylistDocument(response?.data, ctx?.type)) {
2641
+ callbacks.onError(
2642
+ { code: 0, text: PLAYLIST_INVALID_TEXT },
2643
+ ctx,
2644
+ networkDetails,
2645
+ stats
2646
+ );
2647
+ return;
2648
+ }
2649
+ callbacks.onSuccess(response, stats, ctx, networkDetails);
2650
+ }
2651
+ };
2652
+ super.load(context, config, wrapped);
2653
+ }
2654
+ };
2575
2655
  }
2576
2656
  var DEFAULT_CONFIG$4 = {
2577
2657
  debug: false,
@@ -2594,14 +2674,16 @@ var DEFAULT_CONFIG$4 = {
2594
2674
  autoReconnect: true,
2595
2675
  reconnectBaseDelayMs: 2e3,
2596
2676
  reconnectMaxDelayMs: 3e4,
2597
- reconnectWindowMs: 3e5
2677
+ reconnectWindowMs: 3e5,
2678
+ // Never index a malformed live playlist refresh blindly
2679
+ validatePlaylists: true
2598
2680
  };
2599
2681
  var MANIFEST_PHASE_ERRORS = [
2600
2682
  "manifestLoadError",
2601
2683
  "manifestLoadTimeOut",
2602
2684
  "manifestParsingError"
2603
2685
  ];
2604
- function createHLSPlugin(config) {
2686
+ function createHLSPluginWith(loader, variant, config) {
2605
2687
  const mergedConfig = { ...DEFAULT_CONFIG$4, ...config };
2606
2688
  let api = null;
2607
2689
  let hls = null;
@@ -2611,6 +2693,8 @@ function createHLSPlugin(config) {
2611
2693
  let cleanupHlsEvents = null;
2612
2694
  let cleanupVideoEvents = null;
2613
2695
  let isAutoQuality = true;
2696
+ let loadSession = 0;
2697
+ let abortPendingLoad = null;
2614
2698
  let networkRetryCount = 0;
2615
2699
  let mediaRetryCount = 0;
2616
2700
  let retryTimeout = null;
@@ -2643,7 +2727,9 @@ function createHLSPlugin(config) {
2643
2727
  api?.container.appendChild(video);
2644
2728
  return video;
2645
2729
  };
2646
- const cleanup = () => {
2730
+ const teardownPipeline = (reason) => {
2731
+ abortPendingLoad?.(reason ?? new Error("HLS load cancelled"));
2732
+ abortPendingLoad = null;
2647
2733
  cleanupHlsEvents?.();
2648
2734
  cleanupHlsEvents = null;
2649
2735
  cleanupVideoEvents?.();
@@ -2656,6 +2742,9 @@ function createHLSPlugin(config) {
2656
2742
  hls.destroy();
2657
2743
  hls = null;
2658
2744
  }
2745
+ };
2746
+ const cleanup = (reason) => {
2747
+ teardownPipeline(reason);
2659
2748
  currentSrc = null;
2660
2749
  isNative = false;
2661
2750
  isAutoQuality = true;
@@ -2664,7 +2753,17 @@ function createHLSPlugin(config) {
2664
2753
  errorCount = 0;
2665
2754
  errorWindowStart = 0;
2666
2755
  };
2667
- const buildHlsConfig = () => ({
2756
+ const buildHlsConfig = () => {
2757
+ const config2 = buildBaseHlsConfig();
2758
+ if (mergedConfig.validatePlaylists !== false) {
2759
+ const Hls = loader.getHlsConstructor();
2760
+ if (Hls && Hls.DefaultConfig?.loader) {
2761
+ config2.pLoader = createValidatingPlaylistLoader(Hls);
2762
+ }
2763
+ }
2764
+ return config2;
2765
+ };
2766
+ const buildBaseHlsConfig = () => ({
2668
2767
  debug: mergedConfig.debug,
2669
2768
  autoStartLoad: mergedConfig.autoStartLoad,
2670
2769
  startPosition: mergedConfig.startPosition,
@@ -2692,7 +2791,21 @@ function createHLSPlugin(config) {
2692
2791
  const jitter = delay * (0.7 + Math.random() * 0.3);
2693
2792
  return jitter;
2694
2793
  };
2794
+ const APPEND_ERROR_DETAILS = [
2795
+ "bufferAppendError",
2796
+ "bufferAppendingError",
2797
+ "bufferAddCodecError"
2798
+ ];
2695
2799
  const mapFatalErrorCode = (error) => {
2800
+ if (error.response?.text === PLAYLIST_INVALID_TEXT) {
2801
+ return ErrorCode.PLAYLIST_INVALID;
2802
+ }
2803
+ if (error.details === "bufferFullError") {
2804
+ return ErrorCode.MEDIA_BUFFER_FULL;
2805
+ }
2806
+ if (APPEND_ERROR_DETAILS.includes(error.details)) {
2807
+ return ErrorCode.MEDIA_APPEND_ERROR;
2808
+ }
2696
2809
  switch (error.type) {
2697
2810
  case "network":
2698
2811
  return ErrorCode.MEDIA_NETWORK_ERROR;
@@ -2717,7 +2830,7 @@ function createHLSPlugin(config) {
2717
2830
  maybeScheduleReconnect(error);
2718
2831
  };
2719
2832
  const handleHlsError = (error) => {
2720
- const Hls = getHlsConstructor();
2833
+ const Hls = loader.getHlsConstructor();
2721
2834
  if (!Hls || !hls) return false;
2722
2835
  const now = Date.now();
2723
2836
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
@@ -2729,10 +2842,7 @@ function createHLSPlugin(config) {
2729
2842
  if (errorCount >= MAX_ERRORS_IN_WINDOW) {
2730
2843
  api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
2731
2844
  emitFatalError(error, true);
2732
- cleanupHlsEvents?.();
2733
- cleanupHlsEvents = null;
2734
- hls.destroy();
2735
- hls = null;
2845
+ teardownPipeline(new Error(error.details));
2736
2846
  return true;
2737
2847
  }
2738
2848
  if (error.fatal) {
@@ -2753,8 +2863,9 @@ function createHLSPlugin(config) {
2753
2863
  clearTimeout(retryTimeout);
2754
2864
  }
2755
2865
  const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
2866
+ const retry_session = loadSession;
2756
2867
  retryTimeout = setTimeout(() => {
2757
- if (!hls) return;
2868
+ if (retry_session !== loadSession || !hls) return;
2758
2869
  if (isManifestPhase && currentSrc) {
2759
2870
  hls.loadSource(currentSrc);
2760
2871
  } else {
@@ -2777,10 +2888,10 @@ function createHLSPlugin(config) {
2777
2888
  if (retryTimeout) {
2778
2889
  clearTimeout(retryTimeout);
2779
2890
  }
2891
+ const retry_session = loadSession;
2780
2892
  retryTimeout = setTimeout(() => {
2781
- if (hls) {
2782
- hls.recoverMediaError();
2783
- }
2893
+ if (retry_session !== loadSession || !hls) return;
2894
+ hls.recoverMediaError();
2784
2895
  }, delay);
2785
2896
  break;
2786
2897
  }
@@ -2792,6 +2903,7 @@ function createHLSPlugin(config) {
2792
2903
  return false;
2793
2904
  };
2794
2905
  const loadNative = async (src) => {
2906
+ const session = loadSession;
2795
2907
  const videoEl = getOrCreateVideo();
2796
2908
  isNative = true;
2797
2909
  if (api) {
@@ -2799,7 +2911,12 @@ function createHLSPlugin(config) {
2799
2911
  }
2800
2912
  return new Promise((resolve, reject) => {
2801
2913
  let watchdog = null;
2914
+ let settled = false;
2802
2915
  const settle = () => {
2916
+ settled = true;
2917
+ if (abortPendingLoad === abort) {
2918
+ abortPendingLoad = null;
2919
+ }
2803
2920
  videoEl.removeEventListener("loadedmetadata", onLoaded);
2804
2921
  videoEl.removeEventListener("error", onError);
2805
2922
  if (watchdog !== null) {
@@ -2807,7 +2924,19 @@ function createHLSPlugin(config) {
2807
2924
  watchdog = null;
2808
2925
  }
2809
2926
  };
2927
+ const abort = (reason) => {
2928
+ if (settled) return;
2929
+ settle();
2930
+ reject(reason);
2931
+ };
2932
+ abortPendingLoad = abort;
2810
2933
  const onLoaded = () => {
2934
+ if (settled) return;
2935
+ if (session !== loadSession) {
2936
+ settle();
2937
+ reject(new Error("HLS load cancelled"));
2938
+ return;
2939
+ }
2811
2940
  settle();
2812
2941
  hasPlayedContent = true;
2813
2942
  const onFatalVideoError = () => {
@@ -2830,6 +2959,7 @@ function createHLSPlugin(config) {
2830
2959
  resolve();
2831
2960
  };
2832
2961
  const onError = () => {
2962
+ if (settled) return;
2833
2963
  settle();
2834
2964
  const error = videoEl.error;
2835
2965
  reject(new Error(error?.message || "Failed to load HLS source"));
@@ -2837,6 +2967,7 @@ function createHLSPlugin(config) {
2837
2967
  const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
2838
2968
  if (timeout_ms > 0) {
2839
2969
  watchdog = setTimeout(() => {
2970
+ if (settled || session !== loadSession) return;
2840
2971
  settle();
2841
2972
  reject(new Error("Video took too long to load (network timeout)"));
2842
2973
  }, timeout_ms);
@@ -2848,10 +2979,14 @@ function createHLSPlugin(config) {
2848
2979
  });
2849
2980
  };
2850
2981
  const loadWithHlsJs = async (src) => {
2851
- await loadHlsJs();
2982
+ const session = loadSession;
2983
+ await loader.loadHlsJs();
2984
+ if (session !== loadSession) {
2985
+ throw new Error("HLS load cancelled");
2986
+ }
2852
2987
  const videoEl = getOrCreateVideo();
2853
2988
  isNative = false;
2854
- hls = createHlsInstance(buildHlsConfig());
2989
+ hls = loader.createHlsInstance(buildHlsConfig());
2855
2990
  if (api) {
2856
2991
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
2857
2992
  }
@@ -2868,10 +3003,24 @@ function createHLSPlugin(config) {
2868
3003
  watchdog = null;
2869
3004
  }
2870
3005
  };
3006
+ const abort = (reason) => {
3007
+ if (resolved) return;
3008
+ resolved = true;
3009
+ clearWatchdog();
3010
+ reject(reason);
3011
+ };
3012
+ abortPendingLoad = abort;
3013
+ const releaseAbort = () => {
3014
+ if (abortPendingLoad === abort) {
3015
+ abortPendingLoad = null;
3016
+ }
3017
+ };
2871
3018
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
2872
3019
  onManifestParsed: () => {
3020
+ if (session !== loadSession) return;
2873
3021
  if (!resolved) {
2874
3022
  resolved = true;
3023
+ releaseAbort();
2875
3024
  clearWatchdog();
2876
3025
  hasPlayedContent = true;
2877
3026
  api?.setState("source", { src, type: "application/x-mpegURL" });
@@ -2882,14 +3031,17 @@ function createHLSPlugin(config) {
2882
3031
  onLevelSwitched: () => {
2883
3032
  },
2884
3033
  onError: (error) => {
3034
+ if (session !== loadSession) return;
2885
3035
  const terminal = handleHlsError(error);
2886
3036
  if (terminal && !resolved) {
2887
3037
  resolved = true;
3038
+ releaseAbort();
2888
3039
  clearWatchdog();
2889
3040
  reject(new Error(error.details));
2890
3041
  }
2891
3042
  },
2892
3043
  onFragLoaded: () => {
3044
+ if (session !== loadSession) return;
2893
3045
  if (networkRetryCount > 0 || mediaRetryCount > 0) {
2894
3046
  api?.logger.debug("Playback recovered, resetting retry budgets");
2895
3047
  networkRetryCount = 0;
@@ -2901,17 +3053,11 @@ function createHLSPlugin(config) {
2901
3053
  const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
2902
3054
  if (timeout_ms > 0) {
2903
3055
  watchdog = setTimeout(() => {
2904
- if (resolved) return;
3056
+ if (resolved || session !== loadSession) return;
2905
3057
  resolved = true;
3058
+ releaseAbort();
2906
3059
  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;
3060
+ teardownPipeline();
2915
3061
  reject(new Error("Video took too long to load (network timeout)"));
2916
3062
  }, timeout_ms);
2917
3063
  }
@@ -2958,6 +3104,7 @@ function createHLSPlugin(config) {
2958
3104
  };
2959
3105
  const attemptReconnect = async () => {
2960
3106
  if (!api || !currentSrc) return;
3107
+ const session = ++loadSession;
2961
3108
  reconnectAttempts++;
2962
3109
  const saved_src = currentSrc;
2963
3110
  const was_live = api.getState("live");
@@ -2965,27 +3112,19 @@ function createHLSPlugin(config) {
2965
3112
  const resume_position = reconnectResumePosition;
2966
3113
  api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
2967
3114
  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;
3115
+ teardownPipeline(new Error("HLS load cancelled: reconnecting"));
2978
3116
  networkRetryCount = 0;
2979
3117
  mediaRetryCount = 0;
2980
3118
  errorCount = 0;
2981
3119
  errorWindowStart = 0;
2982
3120
  currentSrc = saved_src;
2983
3121
  api.setState("playbackState", "loading");
2984
- if (was_native && supportsNativeHLS()) {
3122
+ if (was_native && loader.supportsNativeHLS()) {
2985
3123
  await loadNative(saved_src);
2986
3124
  } else {
2987
3125
  await loadWithHlsJs(saved_src);
2988
3126
  }
3127
+ if (session !== loadSession) return;
2989
3128
  if (!was_live && video && resume_position > 0) {
2990
3129
  video.currentTime = resume_position;
2991
3130
  }
@@ -2999,18 +3138,19 @@ function createHLSPlugin(config) {
2999
3138
  } catch {
3000
3139
  }
3001
3140
  } catch {
3141
+ if (session !== loadSession) return;
3002
3142
  api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
3003
3143
  scheduleReconnectAttempt();
3004
3144
  }
3005
3145
  };
3006
3146
  const plugin = {
3007
3147
  id: "hls-provider",
3008
- name: "HLS Provider",
3148
+ name: variant.name,
3009
3149
  version: "1.0.0",
3010
3150
  type: "provider",
3011
- description: "HLS playback provider using hls.js",
3151
+ description: variant.description,
3012
3152
  canPlay(src) {
3013
- if (!isHLSSupported()) return false;
3153
+ if (!loader.isHLSSupported()) return false;
3014
3154
  const url = src.toLowerCase();
3015
3155
  const urlWithoutQuery = url.split("?")[0].split("#")[0];
3016
3156
  if (urlWithoutQuery.endsWith(".m3u8")) return true;
@@ -3020,7 +3160,7 @@ function createHLSPlugin(config) {
3020
3160
  },
3021
3161
  async init(pluginApi) {
3022
3162
  api = pluginApi;
3023
- api.logger.info("HLS plugin initialized");
3163
+ api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
3024
3164
  const unsubPlay = api.on("playback:play", async () => {
3025
3165
  if (!video) return;
3026
3166
  try {
@@ -3108,13 +3248,14 @@ function createHLSPlugin(config) {
3108
3248
  });
3109
3249
  },
3110
3250
  async destroy() {
3111
- api?.logger.info("HLS plugin destroying");
3251
+ api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
3252
+ loadSession++;
3112
3253
  cancelReconnect();
3113
3254
  if (onlineListener && typeof window !== "undefined") {
3114
3255
  window.removeEventListener("online", onlineListener);
3115
3256
  onlineListener = null;
3116
3257
  }
3117
- cleanup();
3258
+ cleanup(new Error("HLS load cancelled: player destroyed"));
3118
3259
  if (video?.parentNode) {
3119
3260
  video.parentNode.removeChild(video);
3120
3261
  }
@@ -3123,25 +3264,27 @@ function createHLSPlugin(config) {
3123
3264
  },
3124
3265
  async loadSource(src) {
3125
3266
  if (!api) throw new Error("Plugin not initialized");
3126
- api.logger.info("Loading HLS source", { src });
3267
+ api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
3268
+ const session = ++loadSession;
3127
3269
  cancelReconnect();
3128
3270
  hasPlayedContent = false;
3129
- cleanup();
3271
+ cleanup(new Error("HLS load cancelled: superseded by a new load"));
3130
3272
  currentSrc = src;
3131
3273
  api.setState("playbackState", "loading");
3132
3274
  api.setState("buffering", true);
3133
- if (api.getState("airplayActive") && supportsNativeHLS()) {
3275
+ if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
3134
3276
  api.logger.info("Using native HLS (AirPlay active)");
3135
3277
  await loadNative(src);
3136
- } else if (isHlsJsSupported()) {
3137
- api.logger.info("Using hls.js for HLS playback");
3278
+ } else if (loader.isHlsJsSupported()) {
3279
+ api.logger.info(`Using ${variant.engineLabel} for HLS playback`);
3138
3280
  await loadWithHlsJs(src);
3139
- } else if (supportsNativeHLS()) {
3281
+ } else if (loader.supportsNativeHLS()) {
3140
3282
  api.logger.info("Using native HLS playback (hls.js not supported)");
3141
3283
  await loadNative(src);
3142
3284
  } else {
3143
3285
  throw new Error("HLS playback not supported in this browser");
3144
3286
  }
3287
+ if (session !== loadSession) return;
3145
3288
  if (video) {
3146
3289
  const muted = api.getState("muted");
3147
3290
  const volume = api.getState("volume");
@@ -3193,7 +3336,7 @@ function createHLSPlugin(config) {
3193
3336
  api?.logger.debug("Already using native HLS");
3194
3337
  return;
3195
3338
  }
3196
- if (!supportsNativeHLS()) {
3339
+ if (!loader.supportsNativeHLS()) {
3197
3340
  api?.logger.warn("Native HLS not supported in this browser");
3198
3341
  return;
3199
3342
  }
@@ -3205,8 +3348,10 @@ function createHLSPlugin(config) {
3205
3348
  const wasPlaying = api?.getState("playing") || false;
3206
3349
  const currentTime = video?.currentTime || 0;
3207
3350
  const savedSrc = currentSrc;
3208
- cleanup();
3351
+ const session = ++loadSession;
3352
+ cleanup(new Error("HLS load cancelled: switching to native HLS"));
3209
3353
  await loadNative(savedSrc);
3354
+ if (session !== loadSession) return;
3210
3355
  if (video && currentTime > 0) {
3211
3356
  video.currentTime = currentTime;
3212
3357
  }
@@ -3228,7 +3373,7 @@ function createHLSPlugin(config) {
3228
3373
  api?.logger.debug("Already using hls.js");
3229
3374
  return;
3230
3375
  }
3231
- if (!isHlsJsSupported()) {
3376
+ if (!loader.isHlsJsSupported()) {
3232
3377
  api?.logger.warn("hls.js not supported in this browser");
3233
3378
  return;
3234
3379
  }
@@ -3240,8 +3385,10 @@ function createHLSPlugin(config) {
3240
3385
  const wasPlaying = api?.getState("playing") || false;
3241
3386
  const currentTime = video?.currentTime || 0;
3242
3387
  const savedSrc = currentSrc;
3243
- cleanup();
3388
+ const session = ++loadSession;
3389
+ cleanup(new Error("HLS load cancelled: switching to hls.js"));
3244
3390
  await loadWithHlsJs(savedSrc);
3391
+ if (session !== loadSession) return;
3245
3392
  if (video && currentTime > 0) {
3246
3393
  video.currentTime = currentTime;
3247
3394
  }
@@ -3257,6 +3404,90 @@ function createHLSPlugin(config) {
3257
3404
  };
3258
3405
  return plugin;
3259
3406
  }
3407
+ var hls_loader_exports = {};
3408
+ __export(hls_loader_exports, {
3409
+ createHlsInstance: () => createHlsInstance,
3410
+ getHlsConstructor: () => getHlsConstructor,
3411
+ isHLSSupported: () => isHLSSupported,
3412
+ isHlsJsSupported: () => isHlsJsSupported,
3413
+ loadHlsJs: () => loadHlsJs,
3414
+ resetLoader: () => resetLoader,
3415
+ shouldPreferNativeHLS: () => shouldPreferNativeHLS,
3416
+ supportsNativeHLS: () => supportsNativeHLS
3417
+ });
3418
+ var hlsConstructor = null;
3419
+ var loadingPromise = null;
3420
+ function supportsNativeHLS() {
3421
+ if (typeof document === "undefined") return false;
3422
+ const video = document.createElement("video");
3423
+ return video.canPlayType("application/vnd.apple.mpegurl") !== "";
3424
+ }
3425
+ function shouldPreferNativeHLS() {
3426
+ if (!supportsNativeHLS()) return false;
3427
+ if (typeof navigator === "undefined") return false;
3428
+ const ua = navigator.userAgent;
3429
+ const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/CriOS/.test(ua);
3430
+ return isSafari;
3431
+ }
3432
+ function isHlsJsSupported() {
3433
+ if (hlsConstructor) {
3434
+ return hlsConstructor.isSupported();
3435
+ }
3436
+ if (typeof window === "undefined") return false;
3437
+ return !!(window.MediaSource || window.WebKitMediaSource);
3438
+ }
3439
+ function isHLSSupported() {
3440
+ return supportsNativeHLS() || isHlsJsSupported();
3441
+ }
3442
+ async function loadHlsJs() {
3443
+ if (hlsConstructor) {
3444
+ return hlsConstructor;
3445
+ }
3446
+ if (loadingPromise) {
3447
+ return loadingPromise;
3448
+ }
3449
+ loadingPromise = (async () => {
3450
+ try {
3451
+ const hlsModule = await import("./hls.js");
3452
+ hlsConstructor = hlsModule.default;
3453
+ if (!hlsConstructor.isSupported()) {
3454
+ throw new Error("hls.js is not supported in this browser");
3455
+ }
3456
+ return hlsConstructor;
3457
+ } catch (error) {
3458
+ loadingPromise = null;
3459
+ throw new Error(
3460
+ `Failed to load hls.js: ${error instanceof Error ? error.message : "Unknown error"}`
3461
+ );
3462
+ }
3463
+ })();
3464
+ return loadingPromise;
3465
+ }
3466
+ function createHlsInstance(config) {
3467
+ if (!hlsConstructor) {
3468
+ throw new Error("hls.js is not loaded. Call loadHlsJs() first.");
3469
+ }
3470
+ return new hlsConstructor(config);
3471
+ }
3472
+ function getHlsConstructor() {
3473
+ return hlsConstructor;
3474
+ }
3475
+ function resetLoader() {
3476
+ hlsConstructor = null;
3477
+ loadingPromise = null;
3478
+ }
3479
+ function createHLSPlugin(config) {
3480
+ return createHLSPluginWith(
3481
+ hls_loader_exports,
3482
+ {
3483
+ name: "HLS Provider",
3484
+ description: "HLS playback provider using hls.js",
3485
+ logSuffix: "",
3486
+ engineLabel: "hls.js"
3487
+ },
3488
+ config
3489
+ );
3490
+ }
3260
3491
  var styles = `
3261
3492
  /* ============================================
3262
3493
  Container & Base
@@ -4107,6 +4338,7 @@ var icons = {
4107
4338
  fullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`,
4108
4339
  exitFullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`,
4109
4340
  pip: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>`,
4341
+ exitPip: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 9h6v2H9z"/></svg>`,
4110
4342
  settings: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`,
4111
4343
  chromecast: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
4112
4344
  chromecastConnected: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
@@ -4992,25 +5224,38 @@ var CastButton = class {
4992
5224
  var PipButton = class {
4993
5225
  constructor(api) {
4994
5226
  this.clickHandler = () => {
4995
- this.toggle();
5227
+ void this.toggle().catch(() => {
5228
+ });
4996
5229
  };
4997
5230
  this.api = api;
4998
- const video = document.createElement("video");
4999
- this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in video;
5231
+ const probe = document.createElement("video");
5232
+ this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in probe;
5000
5233
  this.el = createButton("sp-pip", "Picture-in-Picture", icons.pip);
5001
5234
  this.el.addEventListener("click", this.clickHandler);
5002
5235
  if (!this.supported) {
5003
5236
  this.el.style.display = "none";
5237
+ } else {
5238
+ this.el.disabled = true;
5239
+ this.el.setAttribute("aria-disabled", "true");
5004
5240
  }
5005
5241
  }
5006
5242
  render() {
5007
5243
  return this.el;
5008
5244
  }
5245
+ /** Whether the media element is ready to enter PiP (metadata loaded). */
5246
+ isMediaReady() {
5247
+ const video = getVideo(this.api.container);
5248
+ return !!video && video.readyState >= HTMLMediaElement.HAVE_METADATA;
5249
+ }
5009
5250
  update() {
5010
5251
  if (!this.supported) return;
5011
- const pip = this.api.getState("pip");
5012
- this.el.setAttribute("aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
5013
- this.el.classList.toggle("sp-pip--active", !!pip);
5252
+ const pip = !!this.api.getState("pip");
5253
+ const enabled = pip || this.isMediaReady();
5254
+ this.el.disabled = !enabled;
5255
+ setAttr(this.el, "aria-disabled", String(!enabled));
5256
+ setHTML(this.el, pip ? icons.exitPip : icons.pip);
5257
+ setAttr(this.el, "aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
5258
+ this.el.classList.toggle("sp-pip--active", pip);
5014
5259
  }
5015
5260
  async toggle() {
5016
5261
  const video = getVideo(this.api.container);
@@ -5018,8 +5263,14 @@ var PipButton = class {
5018
5263
  this.api.logger.warn("PiP: video element not found");
5019
5264
  return;
5020
5265
  }
5266
+ const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
5267
+ if (!isInPip && video.readyState < HTMLMediaElement.HAVE_METADATA) {
5268
+ this.api.logger.debug("PiP: ignored, media not ready", {
5269
+ readyState: video.readyState
5270
+ });
5271
+ return;
5272
+ }
5021
5273
  try {
5022
- const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
5023
5274
  if (isInPip) {
5024
5275
  if (document.pictureInPictureElement) {
5025
5276
  await document.exitPictureInPicture();
@@ -5036,7 +5287,8 @@ var PipButton = class {
5036
5287
  this.api.logger.debug("PiP: entered");
5037
5288
  }
5038
5289
  } catch (e) {
5039
- this.api.logger.warn("PiP: failed", { error: e.message });
5290
+ const message = e instanceof Error ? e.message : String(e);
5291
+ this.api.logger.warn("PiP: failed", { error: message });
5040
5292
  }
5041
5293
  }
5042
5294
  destroy() {
@@ -5113,6 +5365,12 @@ function getUserMessage(error) {
5113
5365
  return "Unable to load video. Please try again.";
5114
5366
  case "PLAYBACK_FAILED":
5115
5367
  return "Playback stopped unexpectedly. Please try again.";
5368
+ case "MEDIA_APPEND_ERROR":
5369
+ return "Video playback was interrupted. Please try again.";
5370
+ case "MEDIA_BUFFER_FULL":
5371
+ return "Your device is low on video memory. Close other apps or tabs and try again.";
5372
+ case "PLAYLIST_INVALID":
5373
+ return "The stream is temporarily unavailable. Please try again.";
5116
5374
  }
5117
5375
  }
5118
5376
  const msg = error.message?.toLowerCase() || "";
@@ -5764,6 +6022,17 @@ var BandwidthIndicator = class {
5764
6022
  this.el.remove();
5765
6023
  }
5766
6024
  };
6025
+ var registry = /* @__PURE__ */ new Map();
6026
+ var listeners = /* @__PURE__ */ new Set();
6027
+ function getControlFactory(id) {
6028
+ return registry.get(id) ?? null;
6029
+ }
6030
+ function onControlRegistered(listener) {
6031
+ listeners.add(listener);
6032
+ return () => {
6033
+ listeners.delete(listener);
6034
+ };
6035
+ }
5767
6036
  var DEFAULT_LAYOUT = [
5768
6037
  "play",
5769
6038
  "skip-backward",
@@ -5792,6 +6061,7 @@ function uiPlugin(config = {}) {
5792
6061
  let controls = [];
5793
6062
  let hideTimeout = null;
5794
6063
  let stateUnsubscribe = null;
6064
+ let controlRegistryUnsubscribe = null;
5795
6065
  let errorUnsubscribe = null;
5796
6066
  let reconnectingUnsubscribe = null;
5797
6067
  let recoveredUnsubscribe = null;
@@ -5833,9 +6103,42 @@ function uiPlugin(config = {}) {
5833
6103
  return new FullscreenButton(api);
5834
6104
  case "spacer":
5835
6105
  return new Spacer();
5836
- default:
6106
+ default: {
6107
+ const factory = getControlFactory(slot);
6108
+ if (factory) {
6109
+ try {
6110
+ return factory(api);
6111
+ } catch (error) {
6112
+ api.logger.error(`Control factory for "${slot}" threw`, { error });
6113
+ return null;
6114
+ }
6115
+ }
6116
+ api.logger.warn(`Unknown control slot: ${slot}`);
5837
6117
  return null;
6118
+ }
6119
+ }
6120
+ };
6121
+ const populateControlBar = () => {
6122
+ if (!controlBar) {
6123
+ return;
6124
+ }
6125
+ for (const slot of layout) {
6126
+ const control = createControl(slot);
6127
+ if (control) {
6128
+ controls.push(control);
6129
+ controlBar.appendChild(control.render());
6130
+ }
6131
+ }
6132
+ };
6133
+ const rebuildControlBar = () => {
6134
+ if (!controlBar) {
6135
+ return;
5838
6136
  }
6137
+ controls.forEach((c) => c.destroy());
6138
+ controls = [];
6139
+ controlBar.replaceChildren();
6140
+ populateControlBar();
6141
+ updateControls();
5839
6142
  };
5840
6143
  const updateControls = () => {
5841
6144
  controls.forEach((c) => c.update());
@@ -5904,7 +6207,12 @@ function uiPlugin(config = {}) {
5904
6207
  case " ":
5905
6208
  case "k":
5906
6209
  e.preventDefault();
5907
- video.paused ? video.play() : video.pause();
6210
+ if (video.paused) {
6211
+ video.play().catch(() => {
6212
+ });
6213
+ } else {
6214
+ video.pause();
6215
+ }
5908
6216
  break;
5909
6217
  case "m":
5910
6218
  e.preventDefault();
@@ -5913,9 +6221,11 @@ function uiPlugin(config = {}) {
5913
6221
  case "f":
5914
6222
  e.preventDefault();
5915
6223
  if (document.fullscreenElement) {
5916
- document.exitFullscreen();
6224
+ document.exitFullscreen().catch(() => {
6225
+ });
5917
6226
  } else {
5918
- api.container.requestFullscreen?.();
6227
+ api.container.requestFullscreen?.().catch(() => {
6228
+ });
5919
6229
  }
5920
6230
  break;
5921
6231
  case "ArrowLeft":
@@ -6002,14 +6312,15 @@ function uiPlugin(config = {}) {
6002
6312
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
6003
6313
  controlBar.setAttribute("role", "toolbar");
6004
6314
  controlBar.setAttribute("aria-label", "Video controls");
6005
- for (const slot of layout) {
6006
- const control = createControl(slot);
6007
- if (control) {
6008
- controls.push(control);
6009
- controlBar.appendChild(control.render());
6010
- }
6011
- }
6315
+ populateControlBar();
6012
6316
  container.appendChild(controlBar);
6317
+ controlRegistryUnsubscribe = onControlRegistered((id) => {
6318
+ if (!layout.includes(id)) {
6319
+ return;
6320
+ }
6321
+ api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
6322
+ rebuildControlBar();
6323
+ });
6013
6324
  container.addEventListener("mousemove", handleInteraction);
6014
6325
  container.addEventListener("mouseenter", handleInteraction);
6015
6326
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -6055,6 +6366,8 @@ function uiPlugin(config = {}) {
6055
6366
  }
6056
6367
  document.removeEventListener("keydown", handleKeyDown);
6057
6368
  document.removeEventListener("fullscreenchange", scheduleUpdate);
6369
+ controlRegistryUnsubscribe?.();
6370
+ controlRegistryUnsubscribe = null;
6058
6371
  controls.forEach((c) => c.destroy());
6059
6372
  controls = [];
6060
6373
  progressBar?.destroy();
@@ -8253,10 +8566,18 @@ function createWatermarkPlugin(config = {}) {
8253
8566
  }
8254
8567
  };
8255
8568
  }
8569
+ var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
8570
+ var HLS_INSTANCE_RETRY_MS = 500;
8256
8571
  function createCaptionsPlugin(config = {}) {
8257
8572
  let api = null;
8258
8573
  let video = null;
8259
8574
  let addedTrackElements = [];
8575
+ let hlsTrackElements = [];
8576
+ let hlsSubtitleHandler = null;
8577
+ let observedTextTracks = null;
8578
+ let hlsRetryTimer = null;
8579
+ let hlsRetryUsed = false;
8580
+ let hasAutoSelected = false;
8260
8581
  const extractFromHLS = config.extractFromHLS !== false;
8261
8582
  const autoSelect = config.autoSelect ?? false;
8262
8583
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -8270,10 +8591,18 @@ function createCaptionsPlugin(config = {}) {
8270
8591
  trackEl.parentNode?.removeChild(trackEl);
8271
8592
  }
8272
8593
  addedTrackElements = [];
8594
+ hlsTrackElements = [];
8273
8595
  api?.setState("textTracks", []);
8274
8596
  api?.setState("currentTextTrack", null);
8275
8597
  };
8276
- const addTrackElement = (source) => {
8598
+ const removeHlsTrackElements = () => {
8599
+ for (const trackEl of hlsTrackElements) {
8600
+ trackEl.parentNode?.removeChild(trackEl);
8601
+ addedTrackElements = addedTrackElements.filter((el) => el !== trackEl);
8602
+ }
8603
+ hlsTrackElements = [];
8604
+ };
8605
+ const addTrackElement = (source, origin = "config") => {
8277
8606
  const videoEl = getVideo2();
8278
8607
  if (!videoEl) throw new Error("No video element");
8279
8608
  const trackEl = document.createElement("track");
@@ -8284,6 +8613,9 @@ function createCaptionsPlugin(config = {}) {
8284
8613
  trackEl.default = false;
8285
8614
  videoEl.appendChild(trackEl);
8286
8615
  addedTrackElements.push(trackEl);
8616
+ if (origin === "hls") {
8617
+ hlsTrackElements.push(trackEl);
8618
+ }
8287
8619
  if (trackEl.track) {
8288
8620
  trackEl.track.mode = "disabled";
8289
8621
  }
@@ -8315,6 +8647,7 @@ function createCaptionsPlugin(config = {}) {
8315
8647
  const selectTrack = (trackId) => {
8316
8648
  const videoEl = getVideo2();
8317
8649
  if (!videoEl) return;
8650
+ hasAutoSelected = true;
8318
8651
  for (let i = 0; i < videoEl.textTracks.length; i++) {
8319
8652
  const track = videoEl.textTracks[i];
8320
8653
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -8327,6 +8660,43 @@ function createCaptionsPlugin(config = {}) {
8327
8660
  }
8328
8661
  syncTracksToState();
8329
8662
  };
8663
+ const maybeAutoSelect = () => {
8664
+ if (!autoSelect || hasAutoSelected) return;
8665
+ if (api?.getState("currentTextTrack")) {
8666
+ hasAutoSelected = true;
8667
+ return;
8668
+ }
8669
+ const tracks = api?.getState("textTracks") || [];
8670
+ const match = tracks.find((t) => t.language === defaultLanguage);
8671
+ if (!match) return;
8672
+ selectTrack(match.id);
8673
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
8674
+ };
8675
+ const handleTextTracksChanged = () => {
8676
+ syncTracksToState();
8677
+ maybeAutoSelect();
8678
+ };
8679
+ const observeTextTracks = () => {
8680
+ const videoEl = getVideo2();
8681
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
8682
+ unobserveTextTracks();
8683
+ const list = videoEl.textTracks;
8684
+ if (typeof list?.addEventListener !== "function") return;
8685
+ observedTextTracks = list;
8686
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
8687
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
8688
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
8689
+ };
8690
+ const unobserveTextTracks = () => {
8691
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
8692
+ observedTextTracks = null;
8693
+ return;
8694
+ }
8695
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
8696
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
8697
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
8698
+ observedTextTracks = null;
8699
+ };
8330
8700
  const extractHlsSubtitles = () => {
8331
8701
  if (!extractFromHLS || !api) return;
8332
8702
  const hlsPlugin = api.getPlugin("hls-provider");
@@ -8336,35 +8706,55 @@ function createCaptionsPlugin(config = {}) {
8336
8706
  api.logger.debug("Extracting HLS subtitle tracks", {
8337
8707
  count: hlsInstance.subtitleTracks.length
8338
8708
  });
8709
+ removeHlsTrackElements();
8339
8710
  for (const hlsTrack of hlsInstance.subtitleTracks) {
8340
- addTrackElement({
8341
- language: hlsTrack.lang || "unknown",
8342
- label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
8343
- src: hlsTrack.url,
8344
- kind: "subtitles"
8345
- });
8711
+ addTrackElement(
8712
+ {
8713
+ language: hlsTrack.lang || "unknown",
8714
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
8715
+ src: hlsTrack.url,
8716
+ kind: "subtitles"
8717
+ },
8718
+ "hls"
8719
+ );
8346
8720
  }
8347
8721
  syncTracksToState();
8348
- if (autoSelect) {
8349
- autoSelectTrack();
8350
- }
8722
+ maybeAutoSelect();
8351
8723
  };
8352
- const autoSelectTrack = () => {
8353
- const tracks = api?.getState("textTracks") || [];
8354
- const match = tracks.find((t) => t.language === defaultLanguage);
8355
- if (match) {
8356
- selectTrack(match.id);
8357
- api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
8724
+ const unsubscribeFromHls = () => {
8725
+ if (hlsRetryTimer) {
8726
+ clearTimeout(hlsRetryTimer);
8727
+ hlsRetryTimer = null;
8728
+ }
8729
+ if (!hlsSubtitleHandler) return;
8730
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
8731
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
8732
+ hlsSubtitleHandler = null;
8733
+ };
8734
+ const syncFromHls = () => {
8735
+ if (!extractFromHLS || !api) return;
8736
+ const hlsPlugin = api.getPlugin("hls-provider");
8737
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
8738
+ const hlsInstance = hlsPlugin.getHlsInstance();
8739
+ if (!hlsInstance) {
8740
+ if (!hlsRetryUsed) {
8741
+ hlsRetryUsed = true;
8742
+ hlsRetryTimer = setTimeout(() => {
8743
+ hlsRetryTimer = null;
8744
+ syncFromHls();
8745
+ }, HLS_INSTANCE_RETRY_MS);
8746
+ }
8747
+ return;
8358
8748
  }
8749
+ unsubscribeFromHls();
8750
+ hlsSubtitleHandler = () => extractHlsSubtitles();
8751
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
8752
+ extractHlsSubtitles();
8359
8753
  };
8360
8754
  const initSources = () => {
8361
8755
  if (!config.sources?.length) return;
8362
8756
  for (const source of config.sources) {
8363
- addTrackElement(source);
8364
- }
8365
- syncTracksToState();
8366
- if (autoSelect) {
8367
- autoSelectTrack();
8757
+ addTrackElement(source, "config");
8368
8758
  }
8369
8759
  };
8370
8760
  return {
@@ -8383,25 +8773,36 @@ function createCaptionsPlugin(config = {}) {
8383
8773
  });
8384
8774
  const unsubLoaded = api.on("media:loaded", () => {
8385
8775
  video = null;
8776
+ hasAutoSelected = false;
8777
+ hlsRetryUsed = false;
8386
8778
  cleanupTracks();
8387
8779
  initSources();
8388
- if (extractFromHLS) {
8389
- setTimeout(extractHlsSubtitles, 500);
8390
- }
8780
+ observeTextTracks();
8781
+ syncTracksToState();
8782
+ maybeAutoSelect();
8783
+ syncFromHls();
8391
8784
  });
8392
8785
  const unsubLoadRequest = api.on("media:load-request", () => {
8393
8786
  video = null;
8787
+ hasAutoSelected = false;
8788
+ hlsRetryUsed = false;
8789
+ unsubscribeFromHls();
8790
+ unobserveTextTracks();
8394
8791
  cleanupTracks();
8395
8792
  });
8396
8793
  api.onDestroy(() => {
8397
8794
  unsubTrackText();
8398
8795
  unsubLoaded();
8399
8796
  unsubLoadRequest();
8797
+ unsubscribeFromHls();
8798
+ unobserveTextTracks();
8400
8799
  cleanupTracks();
8401
8800
  });
8402
8801
  },
8403
8802
  destroy() {
8404
8803
  api?.logger.debug("Captions plugin destroyed");
8804
+ unsubscribeFromHls();
8805
+ unobserveTextTracks();
8405
8806
  cleanupTracks();
8406
8807
  video = null;
8407
8808
  api = null;