@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.audio.js +861 -630
- package/dist/embed.audio.js.map +1 -1
- package/dist/embed.audio.umd.cjs +1 -1
- package/dist/embed.audio.umd.cjs.map +1 -1
- package/dist/embed.js +1032 -631
- package/dist/embed.js.map +1 -1
- package/dist/embed.umd.cjs +1 -1
- package/dist/embed.umd.cjs.map +1 -1
- package/dist/embed.video.js +1032 -631
- package/dist/embed.video.js.map +1 -1
- package/dist/embed.video.umd.cjs +1 -1
- package/dist/embed.video.umd.cjs.map +1 -1
- package/package.json +10 -10
package/dist/embed.audio.js
CHANGED
|
@@ -1,516 +1,231 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
class Signal {
|
|
2
|
+
constructor(initialValue) {
|
|
3
|
+
this.subscribers = /* @__PURE__ */ new Set();
|
|
4
|
+
this.value = initialValue;
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
+
this.value = newValue;
|
|
24
|
+
this.notify();
|
|
21
25
|
}
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
180
|
+
* Register a state key at runtime, for state a plugin owns.
|
|
332
181
|
*
|
|
333
|
-
* @
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
344
|
-
if (
|
|
200
|
+
define(key, initialValue) {
|
|
201
|
+
if (this.signals.has(key)) {
|
|
345
202
|
return;
|
|
346
203
|
}
|
|
347
|
-
this.
|
|
348
|
-
this.
|
|
204
|
+
this.definedDefaults.set(key, initialValue);
|
|
205
|
+
this.createSignal(key, initialValue);
|
|
349
206
|
}
|
|
350
207
|
/**
|
|
351
|
-
*
|
|
208
|
+
* Get the signal for a state property.
|
|
352
209
|
*
|
|
353
|
-
* @param
|
|
210
|
+
* @param key - State property key
|
|
211
|
+
* @returns Signal for the property
|
|
354
212
|
*
|
|
355
213
|
* @example
|
|
356
214
|
* ```ts
|
|
357
|
-
* const
|
|
358
|
-
*
|
|
215
|
+
* const playingSignal = state.get('playing');
|
|
216
|
+
* playingSignal.get(); // false
|
|
217
|
+
* playingSignal.set(true);
|
|
359
218
|
* ```
|
|
360
219
|
*/
|
|
361
|
-
|
|
362
|
-
this.
|
|
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
|
-
*
|
|
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(
|
|
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);
|
|
@@ -2473,105 +2232,426 @@ class ScarlettPlayer {
|
|
|
2473
2232
|
if (this.destroyed) {
|
|
2474
2233
|
throw new Error("Cannot call methods on destroyed player");
|
|
2475
2234
|
}
|
|
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;
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Detect MIME type from source URL.
|
|
2238
|
+
* @private
|
|
2239
|
+
*/
|
|
2240
|
+
detectMimeType(source) {
|
|
2241
|
+
let path = source;
|
|
2242
|
+
try {
|
|
2243
|
+
path = new URL(source).pathname;
|
|
2244
|
+
} catch {
|
|
2245
|
+
const noQuery = source.split("?")[0] ?? source;
|
|
2246
|
+
path = noQuery.split("#")[0] ?? noQuery;
|
|
2247
|
+
}
|
|
2248
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
2249
|
+
switch (ext) {
|
|
2250
|
+
case "m3u8":
|
|
2251
|
+
return "application/x-mpegURL";
|
|
2252
|
+
case "mpd":
|
|
2253
|
+
return "application/dash+xml";
|
|
2254
|
+
case "mp4":
|
|
2255
|
+
case "m4v":
|
|
2256
|
+
return "video/mp4";
|
|
2257
|
+
case "webm":
|
|
2258
|
+
return "video/webm";
|
|
2259
|
+
case "ogg":
|
|
2260
|
+
case "ogv":
|
|
2261
|
+
return "video/ogg";
|
|
2262
|
+
case "mov":
|
|
2263
|
+
return "video/quicktime";
|
|
2264
|
+
case "mkv":
|
|
2265
|
+
return "video/x-matroska";
|
|
2266
|
+
case "mp3":
|
|
2267
|
+
return "audio/mpeg";
|
|
2268
|
+
case "wav":
|
|
2269
|
+
return "audio/wav";
|
|
2270
|
+
case "flac":
|
|
2271
|
+
return "audio/flac";
|
|
2272
|
+
case "aac":
|
|
2273
|
+
case "m4a":
|
|
2274
|
+
return "audio/mp4";
|
|
2275
|
+
default:
|
|
2276
|
+
return "video/mp4";
|
|
2277
|
+
}
|
|
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");
|
|
2488
2573
|
}
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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";
|
|
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") });
|
|
2518
2580
|
}
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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");
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
);
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
return () => {
|
|
2608
|
+
for (const { event, handler } of handlers) {
|
|
2609
|
+
video.removeEventListener(event, handler);
|
|
2563
2610
|
}
|
|
2564
|
-
|
|
2565
|
-
|
|
2611
|
+
handlers.length = 0;
|
|
2612
|
+
};
|
|
2566
2613
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
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);
|
|
2622
|
+
}
|
|
2623
|
+
return true;
|
|
2572
2624
|
}
|
|
2573
|
-
function
|
|
2574
|
-
|
|
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$3 = {
|
|
2577
2657
|
debug: false,
|
|
@@ -2594,14 +2674,16 @@ var DEFAULT_CONFIG$3 = {
|
|
|
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
|
|
2686
|
+
function createHLSPluginWith(loader, variant, config) {
|
|
2605
2687
|
const mergedConfig = { ...DEFAULT_CONFIG$3, ...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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3148
|
+
name: variant.name,
|
|
3009
3149
|
version: "1.0.0",
|
|
3010
3150
|
type: "provider",
|
|
3011
|
-
description:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 DEFAULT_THEME = {
|
|
3261
3492
|
primary: "#6366f1",
|
|
3262
3493
|
background: "#18181b",
|