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