@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.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);
|
|
@@ -2516,62 +2275,383 @@ class ScarlettPlayer {
|
|
|
2516
2275
|
default:
|
|
2517
2276
|
return "video/mp4";
|
|
2518
2277
|
}
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
async function createPlayer(options) {
|
|
2522
|
-
const player = new ScarlettPlayer(options);
|
|
2523
|
-
await player.init();
|
|
2524
|
-
return player;
|
|
2525
|
-
}
|
|
2526
|
-
var
|
|
2527
|
-
var
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
async function createPlayer(options) {
|
|
2281
|
+
const player = new ScarlettPlayer(options);
|
|
2282
|
+
await player.init();
|
|
2283
|
+
return player;
|
|
2284
|
+
}
|
|
2285
|
+
var __defProp = Object.defineProperty;
|
|
2286
|
+
var __export = (target, all) => {
|
|
2287
|
+
for (var name in all)
|
|
2288
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
2289
|
+
};
|
|
2290
|
+
function formatLevel(level) {
|
|
2291
|
+
if (level.name) {
|
|
2292
|
+
return level.name;
|
|
2293
|
+
}
|
|
2294
|
+
if (level.height) {
|
|
2295
|
+
const standardLabels = {
|
|
2296
|
+
2160: "4K",
|
|
2297
|
+
1440: "1440p",
|
|
2298
|
+
1080: "1080p",
|
|
2299
|
+
720: "720p",
|
|
2300
|
+
480: "480p",
|
|
2301
|
+
360: "360p",
|
|
2302
|
+
240: "240p",
|
|
2303
|
+
144: "144p"
|
|
2304
|
+
};
|
|
2305
|
+
const closest = Object.keys(standardLabels).map(Number).sort((a, b) => Math.abs(a - level.height) - Math.abs(b - level.height))[0];
|
|
2306
|
+
if (Math.abs(closest - level.height) <= 20) {
|
|
2307
|
+
return standardLabels[closest];
|
|
2308
|
+
}
|
|
2309
|
+
return `${level.height}p`;
|
|
2310
|
+
}
|
|
2311
|
+
if (level.bitrate) {
|
|
2312
|
+
return formatBitrate(level.bitrate);
|
|
2313
|
+
}
|
|
2314
|
+
return "Unknown";
|
|
2315
|
+
}
|
|
2316
|
+
function formatBitrate(bitrate) {
|
|
2317
|
+
if (bitrate >= 1e6) {
|
|
2318
|
+
return `${(bitrate / 1e6).toFixed(1)} Mbps`;
|
|
2319
|
+
}
|
|
2320
|
+
if (bitrate >= 1e3) {
|
|
2321
|
+
return `${Math.round(bitrate / 1e3)} Kbps`;
|
|
2322
|
+
}
|
|
2323
|
+
return `${bitrate} bps`;
|
|
2324
|
+
}
|
|
2325
|
+
function mapLevels(levels, _currentLevel) {
|
|
2326
|
+
return levels.map((level, index) => ({
|
|
2327
|
+
index,
|
|
2328
|
+
width: level.width || 0,
|
|
2329
|
+
height: level.height || 0,
|
|
2330
|
+
bitrate: level.bitrate || 0,
|
|
2331
|
+
label: formatLevel(level),
|
|
2332
|
+
codec: level.codecSet
|
|
2333
|
+
}));
|
|
2334
|
+
}
|
|
2335
|
+
function getInitialBandwidthEstimate(overrideBps) {
|
|
2336
|
+
const HLS_DEFAULT_ESTIMATE = 5e5;
|
|
2337
|
+
if (overrideBps !== void 0 && overrideBps > 0) {
|
|
2338
|
+
return overrideBps;
|
|
2339
|
+
}
|
|
2340
|
+
const connection = navigator.connection;
|
|
2341
|
+
if (connection?.downlink && connection.downlink > 0) {
|
|
2342
|
+
const bps = connection.downlink * 1e6;
|
|
2343
|
+
return Math.round(bps * 0.85);
|
|
2344
|
+
}
|
|
2345
|
+
return HLS_DEFAULT_ESTIMATE;
|
|
2346
|
+
}
|
|
2347
|
+
var HLS_ERROR_TYPES = {
|
|
2348
|
+
NETWORK_ERROR: "networkError",
|
|
2349
|
+
MEDIA_ERROR: "mediaError",
|
|
2350
|
+
MUX_ERROR: "muxError"
|
|
2351
|
+
};
|
|
2352
|
+
function mapErrorType(hlsType) {
|
|
2353
|
+
switch (hlsType) {
|
|
2354
|
+
case HLS_ERROR_TYPES.NETWORK_ERROR:
|
|
2355
|
+
return "network";
|
|
2356
|
+
case HLS_ERROR_TYPES.MEDIA_ERROR:
|
|
2357
|
+
return "media";
|
|
2358
|
+
case HLS_ERROR_TYPES.MUX_ERROR:
|
|
2359
|
+
return "mux";
|
|
2360
|
+
default:
|
|
2361
|
+
return "other";
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
function parseHlsError(data) {
|
|
2365
|
+
return {
|
|
2366
|
+
type: mapErrorType(data.type),
|
|
2367
|
+
details: data.details || "Unknown error",
|
|
2368
|
+
fatal: data.fatal || false,
|
|
2369
|
+
url: data.url,
|
|
2370
|
+
reason: data.reason,
|
|
2371
|
+
response: data.response
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
function setupHlsEventHandlers(hls, api, callbacks) {
|
|
2375
|
+
const handlers = [];
|
|
2376
|
+
const addHandler = (event, handler) => {
|
|
2377
|
+
hls.on(event, handler);
|
|
2378
|
+
handlers.push({ event, handler });
|
|
2379
|
+
};
|
|
2380
|
+
addHandler("hlsManifestParsed", (_event, data) => {
|
|
2381
|
+
api.logger.debug("HLS manifest parsed", { levels: data.levels.length });
|
|
2382
|
+
const levels = data.levels.map((level, index) => ({
|
|
2383
|
+
id: `level-${index}`,
|
|
2384
|
+
label: formatLevel(level),
|
|
2385
|
+
width: level.width,
|
|
2386
|
+
height: level.height,
|
|
2387
|
+
bitrate: level.bitrate,
|
|
2388
|
+
active: index === hls.currentLevel
|
|
2389
|
+
}));
|
|
2390
|
+
api.setState("qualities", levels);
|
|
2391
|
+
api.emit("quality:levels", {
|
|
2392
|
+
levels: levels.map((l) => ({ id: l.id, label: l.label }))
|
|
2393
|
+
});
|
|
2394
|
+
callbacks.onManifestParsed?.(data.levels);
|
|
2395
|
+
});
|
|
2396
|
+
addHandler("hlsLevelSwitched", (_event, data) => {
|
|
2397
|
+
const level = hls.levels[data.level];
|
|
2398
|
+
const isAuto = callbacks.getIsAutoQuality?.() ?? hls.autoLevelEnabled;
|
|
2399
|
+
api.logger.debug("HLS level switched", { level: data.level, height: level?.height, auto: isAuto });
|
|
2400
|
+
if (level) {
|
|
2401
|
+
const label = isAuto ? `Auto (${formatLevel(level)})` : formatLevel(level);
|
|
2402
|
+
api.setState("currentQuality", {
|
|
2403
|
+
id: isAuto ? "auto" : `level-${data.level}`,
|
|
2404
|
+
label,
|
|
2405
|
+
width: level.width,
|
|
2406
|
+
height: level.height,
|
|
2407
|
+
bitrate: level.bitrate,
|
|
2408
|
+
active: true
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
api.emit("quality:change", {
|
|
2412
|
+
quality: level ? formatLevel(level) : "auto",
|
|
2413
|
+
auto: isAuto
|
|
2414
|
+
});
|
|
2415
|
+
callbacks.onLevelSwitched?.(data.level);
|
|
2416
|
+
});
|
|
2417
|
+
let lastBandwidthUpdate = 0;
|
|
2418
|
+
addHandler("hlsFragLoaded", () => {
|
|
2419
|
+
const now = Date.now();
|
|
2420
|
+
if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
|
|
2421
|
+
lastBandwidthUpdate = now;
|
|
2422
|
+
api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
|
|
2423
|
+
}
|
|
2424
|
+
callbacks.onFragLoaded?.();
|
|
2425
|
+
});
|
|
2426
|
+
addHandler("hlsFragBuffered", () => {
|
|
2427
|
+
api.setState("buffering", false);
|
|
2428
|
+
callbacks.onBufferUpdate?.();
|
|
2429
|
+
});
|
|
2430
|
+
addHandler("hlsFragLoading", () => {
|
|
2431
|
+
api.setState("buffering", true);
|
|
2432
|
+
});
|
|
2433
|
+
addHandler("hlsLevelLoaded", (_event, data) => {
|
|
2434
|
+
if (data.details?.live !== void 0) {
|
|
2435
|
+
api.setState("live", data.details.live);
|
|
2436
|
+
if (data.details.live) {
|
|
2437
|
+
const video = hls.media;
|
|
2438
|
+
if (video && video.seekable && video.seekable.length > 0) {
|
|
2439
|
+
const start = video.seekable.start(0);
|
|
2440
|
+
const end = video.seekable.end(video.seekable.length - 1);
|
|
2441
|
+
api.setState("seekableRange", { start, end });
|
|
2442
|
+
const threshold = (data.details.targetduration ?? 3) * 3;
|
|
2443
|
+
const isAtLiveEdge = end - video.currentTime < threshold;
|
|
2444
|
+
api.setState("liveEdge", isAtLiveEdge);
|
|
2445
|
+
const latency = end - video.currentTime;
|
|
2446
|
+
api.setState("liveLatency", Math.max(0, latency));
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
callbacks.onLiveUpdate?.();
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
addHandler("hlsError", (_event, data) => {
|
|
2453
|
+
const error = parseHlsError(data);
|
|
2454
|
+
const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
|
|
2455
|
+
if (isBufferHoleSeek) {
|
|
2456
|
+
api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
|
|
2457
|
+
details: error.details,
|
|
2458
|
+
reason: error.reason
|
|
2459
|
+
});
|
|
2460
|
+
} else if (error.fatal) {
|
|
2461
|
+
api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
|
|
2462
|
+
type: error.type,
|
|
2463
|
+
details: error.details,
|
|
2464
|
+
url: error.url
|
|
2465
|
+
});
|
|
2466
|
+
} else {
|
|
2467
|
+
api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
|
|
2468
|
+
type: error.type,
|
|
2469
|
+
details: error.details,
|
|
2470
|
+
fatal: error.fatal,
|
|
2471
|
+
url: error.url
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
callbacks.onError?.(error);
|
|
2475
|
+
});
|
|
2476
|
+
return () => {
|
|
2477
|
+
for (const { event, handler } of handlers) {
|
|
2478
|
+
hls.off(event, handler);
|
|
2479
|
+
}
|
|
2480
|
+
handlers.length = 0;
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function setupVideoEventHandlers(video, api) {
|
|
2484
|
+
const handlers = [];
|
|
2485
|
+
const addHandler = (event, handler) => {
|
|
2486
|
+
video.addEventListener(event, handler);
|
|
2487
|
+
handlers.push({ event, handler });
|
|
2488
|
+
};
|
|
2489
|
+
addHandler("play", () => {
|
|
2490
|
+
api.setState("paused", false);
|
|
2491
|
+
});
|
|
2492
|
+
addHandler("playing", () => {
|
|
2493
|
+
api.setState("playing", true);
|
|
2494
|
+
api.setState("paused", false);
|
|
2495
|
+
api.setState("waiting", false);
|
|
2496
|
+
api.setState("buffering", false);
|
|
2497
|
+
api.setState("playbackState", "playing");
|
|
2498
|
+
});
|
|
2499
|
+
addHandler("pause", () => {
|
|
2500
|
+
api.setState("playing", false);
|
|
2501
|
+
api.setState("paused", true);
|
|
2502
|
+
api.setState("playbackState", "paused");
|
|
2503
|
+
});
|
|
2504
|
+
addHandler("ended", () => {
|
|
2505
|
+
api.setState("playing", false);
|
|
2506
|
+
api.setState("ended", true);
|
|
2507
|
+
api.setState("playbackState", "ended");
|
|
2508
|
+
api.emit("playback:ended", void 0);
|
|
2509
|
+
});
|
|
2510
|
+
addHandler("timeupdate", () => {
|
|
2511
|
+
api.setState("currentTime", video.currentTime);
|
|
2512
|
+
api.emit("playback:timeupdate", { currentTime: video.currentTime });
|
|
2513
|
+
const isLive = api.getState("live");
|
|
2514
|
+
if (isLive && video.seekable && video.seekable.length > 0) {
|
|
2515
|
+
const start = video.seekable.start(0);
|
|
2516
|
+
const end = video.seekable.end(video.seekable.length - 1);
|
|
2517
|
+
api.setState("seekableRange", { start, end });
|
|
2518
|
+
const isAtLiveEdge = end - video.currentTime < 10;
|
|
2519
|
+
api.setState("liveEdge", isAtLiveEdge);
|
|
2520
|
+
api.setState("liveLatency", Math.max(0, end - video.currentTime));
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
addHandler("durationchange", () => {
|
|
2524
|
+
api.setState("duration", video.duration || 0);
|
|
2525
|
+
api.emit("media:loadedmetadata", { duration: video.duration || 0 });
|
|
2526
|
+
});
|
|
2527
|
+
addHandler("waiting", () => {
|
|
2528
|
+
api.setState("waiting", true);
|
|
2529
|
+
api.setState("buffering", true);
|
|
2530
|
+
api.emit("media:waiting", void 0);
|
|
2531
|
+
});
|
|
2532
|
+
addHandler("canplay", () => {
|
|
2533
|
+
api.setState("waiting", false);
|
|
2534
|
+
api.setState("playbackState", "ready");
|
|
2535
|
+
api.emit("media:canplay", void 0);
|
|
2536
|
+
});
|
|
2537
|
+
addHandler("canplaythrough", () => {
|
|
2538
|
+
api.setState("buffering", false);
|
|
2539
|
+
api.emit("media:canplaythrough", void 0);
|
|
2540
|
+
});
|
|
2541
|
+
addHandler("progress", () => {
|
|
2542
|
+
if (video.buffered.length > 0) {
|
|
2543
|
+
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
2544
|
+
const bufferedAmount = video.duration > 0 ? bufferedEnd / video.duration : 0;
|
|
2545
|
+
api.setState("bufferedAmount", bufferedAmount);
|
|
2546
|
+
api.setState("buffered", video.buffered);
|
|
2547
|
+
api.emit("media:progress", { buffered: bufferedAmount });
|
|
2548
|
+
}
|
|
2549
|
+
});
|
|
2550
|
+
addHandler("seeking", () => {
|
|
2551
|
+
api.setState("seeking", true);
|
|
2552
|
+
});
|
|
2553
|
+
addHandler("seeked", () => {
|
|
2554
|
+
api.setState("seeking", false);
|
|
2555
|
+
api.emit("playback:seeked", { time: video.currentTime });
|
|
2556
|
+
});
|
|
2557
|
+
addHandler("volumechange", () => {
|
|
2558
|
+
api.setState("volume", video.volume);
|
|
2559
|
+
api.setState("muted", video.muted);
|
|
2560
|
+
api.emit("volume:change", { volume: video.volume, muted: video.muted });
|
|
2561
|
+
});
|
|
2562
|
+
addHandler("ratechange", () => {
|
|
2563
|
+
api.setState("playbackRate", video.playbackRate);
|
|
2564
|
+
api.emit("playback:ratechange", { rate: video.playbackRate });
|
|
2565
|
+
});
|
|
2566
|
+
addHandler("loadedmetadata", () => {
|
|
2567
|
+
api.setState("duration", video.duration);
|
|
2568
|
+
api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
|
|
2569
|
+
});
|
|
2570
|
+
addHandler("loadeddata", () => {
|
|
2571
|
+
if (video.videoWidth > 0) {
|
|
2572
|
+
api.setState("mediaType", "video");
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
addHandler("error", () => {
|
|
2576
|
+
const error = video.error;
|
|
2577
|
+
if (error) {
|
|
2578
|
+
api.logger.error("Video element error", { code: error.code, message: error.message });
|
|
2579
|
+
api.emit("media:error", { error: new Error(error.message || "Video playback error") });
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
addHandler("enterpictureinpicture", () => {
|
|
2583
|
+
api.setState("pip", true);
|
|
2584
|
+
api.logger.debug("PiP: entered (standard)");
|
|
2585
|
+
});
|
|
2586
|
+
addHandler("leavepictureinpicture", () => {
|
|
2587
|
+
api.setState("pip", false);
|
|
2588
|
+
api.logger.debug("PiP: exited (standard)");
|
|
2589
|
+
if (!video.paused || api.getState("playing")) {
|
|
2590
|
+
video.play().catch(() => {
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
const webkitVideo = video;
|
|
2595
|
+
if ("webkitPresentationMode" in video) {
|
|
2596
|
+
addHandler("webkitpresentationmodechanged", () => {
|
|
2597
|
+
const mode = webkitVideo.webkitPresentationMode;
|
|
2598
|
+
const isInPip = mode === "picture-in-picture";
|
|
2599
|
+
api.setState("pip", isInPip);
|
|
2600
|
+
api.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
|
|
2601
|
+
if (mode === "inline" && video.paused) {
|
|
2602
|
+
video.play().catch(() => {
|
|
2603
|
+
});
|
|
2556
2604
|
}
|
|
2557
|
-
|
|
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
|
-
|
|
2614
|
+
var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
|
|
2615
|
+
var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
|
|
2616
|
+
function isValidPlaylistDocument(data, contextType) {
|
|
2617
|
+
if (typeof data !== "string" || data.length === 0) return false;
|
|
2618
|
+
const text = data.trimStart();
|
|
2619
|
+
if (!text.startsWith("#EXTM3U")) return false;
|
|
2620
|
+
if (contextType && MEDIA_PLAYLIST_CONTEXTS.includes(contextType)) {
|
|
2621
|
+
return /^#EXT(?:INF|-X-TARGETDURATION):/m.test(text);
|
|
2570
2622
|
}
|
|
2571
|
-
return
|
|
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$4 = {
|
|
2577
2657
|
debug: false,
|
|
@@ -2594,14 +2674,16 @@ var DEFAULT_CONFIG$4 = {
|
|
|
2594
2674
|
autoReconnect: true,
|
|
2595
2675
|
reconnectBaseDelayMs: 2e3,
|
|
2596
2676
|
reconnectMaxDelayMs: 3e4,
|
|
2597
|
-
reconnectWindowMs: 3e5
|
|
2677
|
+
reconnectWindowMs: 3e5,
|
|
2678
|
+
// Never index a malformed live playlist refresh blindly
|
|
2679
|
+
validatePlaylists: true
|
|
2598
2680
|
};
|
|
2599
2681
|
var MANIFEST_PHASE_ERRORS = [
|
|
2600
2682
|
"manifestLoadError",
|
|
2601
2683
|
"manifestLoadTimeOut",
|
|
2602
2684
|
"manifestParsingError"
|
|
2603
2685
|
];
|
|
2604
|
-
function
|
|
2686
|
+
function createHLSPluginWith(loader, variant, config) {
|
|
2605
2687
|
const mergedConfig = { ...DEFAULT_CONFIG$4, ...config };
|
|
2606
2688
|
let api = null;
|
|
2607
2689
|
let hls = null;
|
|
@@ -2611,6 +2693,8 @@ function createHLSPlugin(config) {
|
|
|
2611
2693
|
let cleanupHlsEvents = null;
|
|
2612
2694
|
let cleanupVideoEvents = null;
|
|
2613
2695
|
let isAutoQuality = true;
|
|
2696
|
+
let loadSession = 0;
|
|
2697
|
+
let abortPendingLoad = null;
|
|
2614
2698
|
let networkRetryCount = 0;
|
|
2615
2699
|
let mediaRetryCount = 0;
|
|
2616
2700
|
let retryTimeout = null;
|
|
@@ -2643,7 +2727,9 @@ function createHLSPlugin(config) {
|
|
|
2643
2727
|
api?.container.appendChild(video);
|
|
2644
2728
|
return video;
|
|
2645
2729
|
};
|
|
2646
|
-
const
|
|
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 styles = `
|
|
3261
3492
|
/* ============================================
|
|
3262
3493
|
Container & Base
|
|
@@ -4107,6 +4338,7 @@ var icons = {
|
|
|
4107
4338
|
fullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`,
|
|
4108
4339
|
exitFullscreen: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`,
|
|
4109
4340
|
pip: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>`,
|
|
4341
|
+
exitPip: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 9h6v2H9z"/></svg>`,
|
|
4110
4342
|
settings: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`,
|
|
4111
4343
|
chromecast: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
|
|
4112
4344
|
chromecastConnected: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm18-7H5v1.63c3.96 1.28 7.09 4.41 8.37 8.37H19V7zM1 10v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zm20-7H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>`,
|
|
@@ -4992,25 +5224,38 @@ var CastButton = class {
|
|
|
4992
5224
|
var PipButton = class {
|
|
4993
5225
|
constructor(api) {
|
|
4994
5226
|
this.clickHandler = () => {
|
|
4995
|
-
this.toggle()
|
|
5227
|
+
void this.toggle().catch(() => {
|
|
5228
|
+
});
|
|
4996
5229
|
};
|
|
4997
5230
|
this.api = api;
|
|
4998
|
-
const
|
|
4999
|
-
this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in
|
|
5231
|
+
const probe = document.createElement("video");
|
|
5232
|
+
this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in probe;
|
|
5000
5233
|
this.el = createButton("sp-pip", "Picture-in-Picture", icons.pip);
|
|
5001
5234
|
this.el.addEventListener("click", this.clickHandler);
|
|
5002
5235
|
if (!this.supported) {
|
|
5003
5236
|
this.el.style.display = "none";
|
|
5237
|
+
} else {
|
|
5238
|
+
this.el.disabled = true;
|
|
5239
|
+
this.el.setAttribute("aria-disabled", "true");
|
|
5004
5240
|
}
|
|
5005
5241
|
}
|
|
5006
5242
|
render() {
|
|
5007
5243
|
return this.el;
|
|
5008
5244
|
}
|
|
5245
|
+
/** Whether the media element is ready to enter PiP (metadata loaded). */
|
|
5246
|
+
isMediaReady() {
|
|
5247
|
+
const video = getVideo(this.api.container);
|
|
5248
|
+
return !!video && video.readyState >= HTMLMediaElement.HAVE_METADATA;
|
|
5249
|
+
}
|
|
5009
5250
|
update() {
|
|
5010
5251
|
if (!this.supported) return;
|
|
5011
|
-
const pip = this.api.getState("pip");
|
|
5012
|
-
|
|
5013
|
-
this.el.
|
|
5252
|
+
const pip = !!this.api.getState("pip");
|
|
5253
|
+
const enabled = pip || this.isMediaReady();
|
|
5254
|
+
this.el.disabled = !enabled;
|
|
5255
|
+
setAttr(this.el, "aria-disabled", String(!enabled));
|
|
5256
|
+
setHTML(this.el, pip ? icons.exitPip : icons.pip);
|
|
5257
|
+
setAttr(this.el, "aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
|
|
5258
|
+
this.el.classList.toggle("sp-pip--active", pip);
|
|
5014
5259
|
}
|
|
5015
5260
|
async toggle() {
|
|
5016
5261
|
const video = getVideo(this.api.container);
|
|
@@ -5018,8 +5263,14 @@ var PipButton = class {
|
|
|
5018
5263
|
this.api.logger.warn("PiP: video element not found");
|
|
5019
5264
|
return;
|
|
5020
5265
|
}
|
|
5266
|
+
const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
|
|
5267
|
+
if (!isInPip && video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
|
5268
|
+
this.api.logger.debug("PiP: ignored, media not ready", {
|
|
5269
|
+
readyState: video.readyState
|
|
5270
|
+
});
|
|
5271
|
+
return;
|
|
5272
|
+
}
|
|
5021
5273
|
try {
|
|
5022
|
-
const isInPip = document.pictureInPictureElement === video || video.webkitPresentationMode === "picture-in-picture";
|
|
5023
5274
|
if (isInPip) {
|
|
5024
5275
|
if (document.pictureInPictureElement) {
|
|
5025
5276
|
await document.exitPictureInPicture();
|
|
@@ -5036,7 +5287,8 @@ var PipButton = class {
|
|
|
5036
5287
|
this.api.logger.debug("PiP: entered");
|
|
5037
5288
|
}
|
|
5038
5289
|
} catch (e) {
|
|
5039
|
-
|
|
5290
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5291
|
+
this.api.logger.warn("PiP: failed", { error: message });
|
|
5040
5292
|
}
|
|
5041
5293
|
}
|
|
5042
5294
|
destroy() {
|
|
@@ -5113,6 +5365,12 @@ function getUserMessage(error) {
|
|
|
5113
5365
|
return "Unable to load video. Please try again.";
|
|
5114
5366
|
case "PLAYBACK_FAILED":
|
|
5115
5367
|
return "Playback stopped unexpectedly. Please try again.";
|
|
5368
|
+
case "MEDIA_APPEND_ERROR":
|
|
5369
|
+
return "Video playback was interrupted. Please try again.";
|
|
5370
|
+
case "MEDIA_BUFFER_FULL":
|
|
5371
|
+
return "Your device is low on video memory. Close other apps or tabs and try again.";
|
|
5372
|
+
case "PLAYLIST_INVALID":
|
|
5373
|
+
return "The stream is temporarily unavailable. Please try again.";
|
|
5116
5374
|
}
|
|
5117
5375
|
}
|
|
5118
5376
|
const msg = error.message?.toLowerCase() || "";
|
|
@@ -5764,6 +6022,17 @@ var BandwidthIndicator = class {
|
|
|
5764
6022
|
this.el.remove();
|
|
5765
6023
|
}
|
|
5766
6024
|
};
|
|
6025
|
+
var registry = /* @__PURE__ */ new Map();
|
|
6026
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
6027
|
+
function getControlFactory(id) {
|
|
6028
|
+
return registry.get(id) ?? null;
|
|
6029
|
+
}
|
|
6030
|
+
function onControlRegistered(listener) {
|
|
6031
|
+
listeners.add(listener);
|
|
6032
|
+
return () => {
|
|
6033
|
+
listeners.delete(listener);
|
|
6034
|
+
};
|
|
6035
|
+
}
|
|
5767
6036
|
var DEFAULT_LAYOUT = [
|
|
5768
6037
|
"play",
|
|
5769
6038
|
"skip-backward",
|
|
@@ -5792,6 +6061,7 @@ function uiPlugin(config = {}) {
|
|
|
5792
6061
|
let controls = [];
|
|
5793
6062
|
let hideTimeout = null;
|
|
5794
6063
|
let stateUnsubscribe = null;
|
|
6064
|
+
let controlRegistryUnsubscribe = null;
|
|
5795
6065
|
let errorUnsubscribe = null;
|
|
5796
6066
|
let reconnectingUnsubscribe = null;
|
|
5797
6067
|
let recoveredUnsubscribe = null;
|
|
@@ -5833,9 +6103,42 @@ function uiPlugin(config = {}) {
|
|
|
5833
6103
|
return new FullscreenButton(api);
|
|
5834
6104
|
case "spacer":
|
|
5835
6105
|
return new Spacer();
|
|
5836
|
-
default:
|
|
6106
|
+
default: {
|
|
6107
|
+
const factory = getControlFactory(slot);
|
|
6108
|
+
if (factory) {
|
|
6109
|
+
try {
|
|
6110
|
+
return factory(api);
|
|
6111
|
+
} catch (error) {
|
|
6112
|
+
api.logger.error(`Control factory for "${slot}" threw`, { error });
|
|
6113
|
+
return null;
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
api.logger.warn(`Unknown control slot: ${slot}`);
|
|
5837
6117
|
return null;
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
};
|
|
6121
|
+
const populateControlBar = () => {
|
|
6122
|
+
if (!controlBar) {
|
|
6123
|
+
return;
|
|
6124
|
+
}
|
|
6125
|
+
for (const slot of layout) {
|
|
6126
|
+
const control = createControl(slot);
|
|
6127
|
+
if (control) {
|
|
6128
|
+
controls.push(control);
|
|
6129
|
+
controlBar.appendChild(control.render());
|
|
6130
|
+
}
|
|
6131
|
+
}
|
|
6132
|
+
};
|
|
6133
|
+
const rebuildControlBar = () => {
|
|
6134
|
+
if (!controlBar) {
|
|
6135
|
+
return;
|
|
5838
6136
|
}
|
|
6137
|
+
controls.forEach((c) => c.destroy());
|
|
6138
|
+
controls = [];
|
|
6139
|
+
controlBar.replaceChildren();
|
|
6140
|
+
populateControlBar();
|
|
6141
|
+
updateControls();
|
|
5839
6142
|
};
|
|
5840
6143
|
const updateControls = () => {
|
|
5841
6144
|
controls.forEach((c) => c.update());
|
|
@@ -5904,7 +6207,12 @@ function uiPlugin(config = {}) {
|
|
|
5904
6207
|
case " ":
|
|
5905
6208
|
case "k":
|
|
5906
6209
|
e.preventDefault();
|
|
5907
|
-
video.paused
|
|
6210
|
+
if (video.paused) {
|
|
6211
|
+
video.play().catch(() => {
|
|
6212
|
+
});
|
|
6213
|
+
} else {
|
|
6214
|
+
video.pause();
|
|
6215
|
+
}
|
|
5908
6216
|
break;
|
|
5909
6217
|
case "m":
|
|
5910
6218
|
e.preventDefault();
|
|
@@ -5913,9 +6221,11 @@ function uiPlugin(config = {}) {
|
|
|
5913
6221
|
case "f":
|
|
5914
6222
|
e.preventDefault();
|
|
5915
6223
|
if (document.fullscreenElement) {
|
|
5916
|
-
document.exitFullscreen()
|
|
6224
|
+
document.exitFullscreen().catch(() => {
|
|
6225
|
+
});
|
|
5917
6226
|
} else {
|
|
5918
|
-
api.container.requestFullscreen?.()
|
|
6227
|
+
api.container.requestFullscreen?.().catch(() => {
|
|
6228
|
+
});
|
|
5919
6229
|
}
|
|
5920
6230
|
break;
|
|
5921
6231
|
case "ArrowLeft":
|
|
@@ -6002,14 +6312,15 @@ function uiPlugin(config = {}) {
|
|
|
6002
6312
|
controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
|
|
6003
6313
|
controlBar.setAttribute("role", "toolbar");
|
|
6004
6314
|
controlBar.setAttribute("aria-label", "Video controls");
|
|
6005
|
-
|
|
6006
|
-
const control = createControl(slot);
|
|
6007
|
-
if (control) {
|
|
6008
|
-
controls.push(control);
|
|
6009
|
-
controlBar.appendChild(control.render());
|
|
6010
|
-
}
|
|
6011
|
-
}
|
|
6315
|
+
populateControlBar();
|
|
6012
6316
|
container.appendChild(controlBar);
|
|
6317
|
+
controlRegistryUnsubscribe = onControlRegistered((id) => {
|
|
6318
|
+
if (!layout.includes(id)) {
|
|
6319
|
+
return;
|
|
6320
|
+
}
|
|
6321
|
+
api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
|
|
6322
|
+
rebuildControlBar();
|
|
6323
|
+
});
|
|
6013
6324
|
container.addEventListener("mousemove", handleInteraction);
|
|
6014
6325
|
container.addEventListener("mouseenter", handleInteraction);
|
|
6015
6326
|
container.addEventListener("mouseleave", handleMouseLeave);
|
|
@@ -6055,6 +6366,8 @@ function uiPlugin(config = {}) {
|
|
|
6055
6366
|
}
|
|
6056
6367
|
document.removeEventListener("keydown", handleKeyDown);
|
|
6057
6368
|
document.removeEventListener("fullscreenchange", scheduleUpdate);
|
|
6369
|
+
controlRegistryUnsubscribe?.();
|
|
6370
|
+
controlRegistryUnsubscribe = null;
|
|
6058
6371
|
controls.forEach((c) => c.destroy());
|
|
6059
6372
|
controls = [];
|
|
6060
6373
|
progressBar?.destroy();
|
|
@@ -8253,10 +8566,18 @@ function createWatermarkPlugin(config = {}) {
|
|
|
8253
8566
|
}
|
|
8254
8567
|
};
|
|
8255
8568
|
}
|
|
8569
|
+
var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
|
|
8570
|
+
var HLS_INSTANCE_RETRY_MS = 500;
|
|
8256
8571
|
function createCaptionsPlugin(config = {}) {
|
|
8257
8572
|
let api = null;
|
|
8258
8573
|
let video = null;
|
|
8259
8574
|
let addedTrackElements = [];
|
|
8575
|
+
let hlsTrackElements = [];
|
|
8576
|
+
let hlsSubtitleHandler = null;
|
|
8577
|
+
let observedTextTracks = null;
|
|
8578
|
+
let hlsRetryTimer = null;
|
|
8579
|
+
let hlsRetryUsed = false;
|
|
8580
|
+
let hasAutoSelected = false;
|
|
8260
8581
|
const extractFromHLS = config.extractFromHLS !== false;
|
|
8261
8582
|
const autoSelect = config.autoSelect ?? false;
|
|
8262
8583
|
const defaultLanguage = config.defaultLanguage ?? "en";
|
|
@@ -8270,10 +8591,18 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8270
8591
|
trackEl.parentNode?.removeChild(trackEl);
|
|
8271
8592
|
}
|
|
8272
8593
|
addedTrackElements = [];
|
|
8594
|
+
hlsTrackElements = [];
|
|
8273
8595
|
api?.setState("textTracks", []);
|
|
8274
8596
|
api?.setState("currentTextTrack", null);
|
|
8275
8597
|
};
|
|
8276
|
-
const
|
|
8598
|
+
const removeHlsTrackElements = () => {
|
|
8599
|
+
for (const trackEl of hlsTrackElements) {
|
|
8600
|
+
trackEl.parentNode?.removeChild(trackEl);
|
|
8601
|
+
addedTrackElements = addedTrackElements.filter((el) => el !== trackEl);
|
|
8602
|
+
}
|
|
8603
|
+
hlsTrackElements = [];
|
|
8604
|
+
};
|
|
8605
|
+
const addTrackElement = (source, origin = "config") => {
|
|
8277
8606
|
const videoEl = getVideo2();
|
|
8278
8607
|
if (!videoEl) throw new Error("No video element");
|
|
8279
8608
|
const trackEl = document.createElement("track");
|
|
@@ -8284,6 +8613,9 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8284
8613
|
trackEl.default = false;
|
|
8285
8614
|
videoEl.appendChild(trackEl);
|
|
8286
8615
|
addedTrackElements.push(trackEl);
|
|
8616
|
+
if (origin === "hls") {
|
|
8617
|
+
hlsTrackElements.push(trackEl);
|
|
8618
|
+
}
|
|
8287
8619
|
if (trackEl.track) {
|
|
8288
8620
|
trackEl.track.mode = "disabled";
|
|
8289
8621
|
}
|
|
@@ -8315,6 +8647,7 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8315
8647
|
const selectTrack = (trackId) => {
|
|
8316
8648
|
const videoEl = getVideo2();
|
|
8317
8649
|
if (!videoEl) return;
|
|
8650
|
+
hasAutoSelected = true;
|
|
8318
8651
|
for (let i = 0; i < videoEl.textTracks.length; i++) {
|
|
8319
8652
|
const track = videoEl.textTracks[i];
|
|
8320
8653
|
if (track.kind !== "subtitles" && track.kind !== "captions") continue;
|
|
@@ -8327,6 +8660,43 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8327
8660
|
}
|
|
8328
8661
|
syncTracksToState();
|
|
8329
8662
|
};
|
|
8663
|
+
const maybeAutoSelect = () => {
|
|
8664
|
+
if (!autoSelect || hasAutoSelected) return;
|
|
8665
|
+
if (api?.getState("currentTextTrack")) {
|
|
8666
|
+
hasAutoSelected = true;
|
|
8667
|
+
return;
|
|
8668
|
+
}
|
|
8669
|
+
const tracks = api?.getState("textTracks") || [];
|
|
8670
|
+
const match = tracks.find((t) => t.language === defaultLanguage);
|
|
8671
|
+
if (!match) return;
|
|
8672
|
+
selectTrack(match.id);
|
|
8673
|
+
api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
|
|
8674
|
+
};
|
|
8675
|
+
const handleTextTracksChanged = () => {
|
|
8676
|
+
syncTracksToState();
|
|
8677
|
+
maybeAutoSelect();
|
|
8678
|
+
};
|
|
8679
|
+
const observeTextTracks = () => {
|
|
8680
|
+
const videoEl = getVideo2();
|
|
8681
|
+
if (!videoEl || observedTextTracks === videoEl.textTracks) return;
|
|
8682
|
+
unobserveTextTracks();
|
|
8683
|
+
const list = videoEl.textTracks;
|
|
8684
|
+
if (typeof list?.addEventListener !== "function") return;
|
|
8685
|
+
observedTextTracks = list;
|
|
8686
|
+
observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
|
|
8687
|
+
observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
|
|
8688
|
+
observedTextTracks.addEventListener("change", handleTextTracksChanged);
|
|
8689
|
+
};
|
|
8690
|
+
const unobserveTextTracks = () => {
|
|
8691
|
+
if (typeof observedTextTracks?.removeEventListener !== "function") {
|
|
8692
|
+
observedTextTracks = null;
|
|
8693
|
+
return;
|
|
8694
|
+
}
|
|
8695
|
+
observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
|
|
8696
|
+
observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
|
|
8697
|
+
observedTextTracks.removeEventListener("change", handleTextTracksChanged);
|
|
8698
|
+
observedTextTracks = null;
|
|
8699
|
+
};
|
|
8330
8700
|
const extractHlsSubtitles = () => {
|
|
8331
8701
|
if (!extractFromHLS || !api) return;
|
|
8332
8702
|
const hlsPlugin = api.getPlugin("hls-provider");
|
|
@@ -8336,35 +8706,55 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8336
8706
|
api.logger.debug("Extracting HLS subtitle tracks", {
|
|
8337
8707
|
count: hlsInstance.subtitleTracks.length
|
|
8338
8708
|
});
|
|
8709
|
+
removeHlsTrackElements();
|
|
8339
8710
|
for (const hlsTrack of hlsInstance.subtitleTracks) {
|
|
8340
|
-
addTrackElement(
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
8711
|
+
addTrackElement(
|
|
8712
|
+
{
|
|
8713
|
+
language: hlsTrack.lang || "unknown",
|
|
8714
|
+
label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
|
|
8715
|
+
src: hlsTrack.url,
|
|
8716
|
+
kind: "subtitles"
|
|
8717
|
+
},
|
|
8718
|
+
"hls"
|
|
8719
|
+
);
|
|
8346
8720
|
}
|
|
8347
8721
|
syncTracksToState();
|
|
8348
|
-
|
|
8349
|
-
autoSelectTrack();
|
|
8350
|
-
}
|
|
8722
|
+
maybeAutoSelect();
|
|
8351
8723
|
};
|
|
8352
|
-
const
|
|
8353
|
-
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8724
|
+
const unsubscribeFromHls = () => {
|
|
8725
|
+
if (hlsRetryTimer) {
|
|
8726
|
+
clearTimeout(hlsRetryTimer);
|
|
8727
|
+
hlsRetryTimer = null;
|
|
8728
|
+
}
|
|
8729
|
+
if (!hlsSubtitleHandler) return;
|
|
8730
|
+
const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
|
|
8731
|
+
hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
|
|
8732
|
+
hlsSubtitleHandler = null;
|
|
8733
|
+
};
|
|
8734
|
+
const syncFromHls = () => {
|
|
8735
|
+
if (!extractFromHLS || !api) return;
|
|
8736
|
+
const hlsPlugin = api.getPlugin("hls-provider");
|
|
8737
|
+
if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
|
|
8738
|
+
const hlsInstance = hlsPlugin.getHlsInstance();
|
|
8739
|
+
if (!hlsInstance) {
|
|
8740
|
+
if (!hlsRetryUsed) {
|
|
8741
|
+
hlsRetryUsed = true;
|
|
8742
|
+
hlsRetryTimer = setTimeout(() => {
|
|
8743
|
+
hlsRetryTimer = null;
|
|
8744
|
+
syncFromHls();
|
|
8745
|
+
}, HLS_INSTANCE_RETRY_MS);
|
|
8746
|
+
}
|
|
8747
|
+
return;
|
|
8358
8748
|
}
|
|
8749
|
+
unsubscribeFromHls();
|
|
8750
|
+
hlsSubtitleHandler = () => extractHlsSubtitles();
|
|
8751
|
+
hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
|
|
8752
|
+
extractHlsSubtitles();
|
|
8359
8753
|
};
|
|
8360
8754
|
const initSources = () => {
|
|
8361
8755
|
if (!config.sources?.length) return;
|
|
8362
8756
|
for (const source of config.sources) {
|
|
8363
|
-
addTrackElement(source);
|
|
8364
|
-
}
|
|
8365
|
-
syncTracksToState();
|
|
8366
|
-
if (autoSelect) {
|
|
8367
|
-
autoSelectTrack();
|
|
8757
|
+
addTrackElement(source, "config");
|
|
8368
8758
|
}
|
|
8369
8759
|
};
|
|
8370
8760
|
return {
|
|
@@ -8383,25 +8773,36 @@ function createCaptionsPlugin(config = {}) {
|
|
|
8383
8773
|
});
|
|
8384
8774
|
const unsubLoaded = api.on("media:loaded", () => {
|
|
8385
8775
|
video = null;
|
|
8776
|
+
hasAutoSelected = false;
|
|
8777
|
+
hlsRetryUsed = false;
|
|
8386
8778
|
cleanupTracks();
|
|
8387
8779
|
initSources();
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8780
|
+
observeTextTracks();
|
|
8781
|
+
syncTracksToState();
|
|
8782
|
+
maybeAutoSelect();
|
|
8783
|
+
syncFromHls();
|
|
8391
8784
|
});
|
|
8392
8785
|
const unsubLoadRequest = api.on("media:load-request", () => {
|
|
8393
8786
|
video = null;
|
|
8787
|
+
hasAutoSelected = false;
|
|
8788
|
+
hlsRetryUsed = false;
|
|
8789
|
+
unsubscribeFromHls();
|
|
8790
|
+
unobserveTextTracks();
|
|
8394
8791
|
cleanupTracks();
|
|
8395
8792
|
});
|
|
8396
8793
|
api.onDestroy(() => {
|
|
8397
8794
|
unsubTrackText();
|
|
8398
8795
|
unsubLoaded();
|
|
8399
8796
|
unsubLoadRequest();
|
|
8797
|
+
unsubscribeFromHls();
|
|
8798
|
+
unobserveTextTracks();
|
|
8400
8799
|
cleanupTracks();
|
|
8401
8800
|
});
|
|
8402
8801
|
},
|
|
8403
8802
|
destroy() {
|
|
8404
8803
|
api?.logger.debug("Captions plugin destroyed");
|
|
8804
|
+
unsubscribeFromHls();
|
|
8805
|
+
unobserveTextTracks();
|
|
8405
8806
|
cleanupTracks();
|
|
8406
8807
|
video = null;
|
|
8407
8808
|
api = null;
|