@scalemule/gallop 0.0.1 → 0.0.2
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/package.json +1 -1
- package/dist/EventEmitter-CiUv3YL_.d.cts +0 -12
- package/dist/EventEmitter-CkfpgRij.d.ts +0 -12
- package/dist/chunk-2JQGJ7NX.cjs +0 -40
- package/dist/chunk-PKRNWEEX.cjs +0 -265
- package/dist/chunk-QTV4W7FA.js +0 -2886
- package/dist/chunk-SQPWH6EI.js +0 -38
- package/dist/chunk-UFFGSURS.js +0 -263
- package/dist/chunk-VCNMR5AB.cjs +0 -2893
- package/dist/element.cjs +0 -342
- package/dist/element.d.cts +0 -38
- package/dist/element.d.ts +0 -38
- package/dist/element.js +0 -340
- package/dist/gallop.embed.global.js +0 -568
- package/dist/gallop.umd.global.js +0 -568
- package/dist/iframe.cjs +0 -11
- package/dist/iframe.d.cts +0 -50
- package/dist/iframe.d.ts +0 -50
- package/dist/iframe.js +0 -2
- package/dist/index.cjs +0 -11
- package/dist/index.d.cts +0 -74
- package/dist/index.d.ts +0 -74
- package/dist/index.js +0 -2
- package/dist/react.cjs +0 -77
- package/dist/react.d.cts +0 -34
- package/dist/react.d.ts +0 -34
- package/dist/react.js +0 -74
- package/dist/types-D9Oqcpr1.d.cts +0 -235
- package/dist/types-D9Oqcpr1.d.ts +0 -235
package/dist/chunk-VCNMR5AB.cjs
DELETED
|
@@ -1,2893 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var chunk2JQGJ7NX_cjs = require('./chunk-2JQGJ7NX.cjs');
|
|
4
|
-
var Hls = require('hls.js');
|
|
5
|
-
|
|
6
|
-
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
-
|
|
8
|
-
var Hls__default = /*#__PURE__*/_interopDefault(Hls);
|
|
9
|
-
|
|
10
|
-
// src/constants.ts
|
|
11
|
-
var DEFAULT_THEME = {
|
|
12
|
-
colorPrimary: "#635bff",
|
|
13
|
-
colorSecondary: "#8b5cf6",
|
|
14
|
-
colorText: "#ffffff",
|
|
15
|
-
colorBackground: "rgba(24, 24, 32, 0.92)",
|
|
16
|
-
colorBuffered: "rgba(255, 255, 255, 0.22)",
|
|
17
|
-
colorProgress: "#635bff",
|
|
18
|
-
controlBarBackground: "rgba(0, 0, 0, 0.65)",
|
|
19
|
-
controlBarHeight: "44px",
|
|
20
|
-
borderRadius: "8px",
|
|
21
|
-
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
22
|
-
fontSize: "13px",
|
|
23
|
-
iconSize: "22px"
|
|
24
|
-
};
|
|
25
|
-
var DEFAULT_CONFIG = {
|
|
26
|
-
autoplay: false,
|
|
27
|
-
loop: false,
|
|
28
|
-
muted: false,
|
|
29
|
-
aspectRatio: "16:9",
|
|
30
|
-
apiBaseUrl: "https://api.scalemule.com"
|
|
31
|
-
};
|
|
32
|
-
var SPEED_PRESETS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
33
|
-
var SEEK_STEP = 5;
|
|
34
|
-
var SEEK_STEP_LARGE = 10;
|
|
35
|
-
var VOLUME_STEP = 0.05;
|
|
36
|
-
var CONTROLS_HIDE_DELAY = 3e3;
|
|
37
|
-
var DOUBLE_TAP_DELAY = 300;
|
|
38
|
-
var HLS_DEFAULT_CONFIG = {
|
|
39
|
-
maxBufferLength: 30,
|
|
40
|
-
maxMaxBufferLength: 60,
|
|
41
|
-
startLevel: -1,
|
|
42
|
-
capLevelToPlayerSize: true,
|
|
43
|
-
progressive: true
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// src/core/PlayerState.ts
|
|
47
|
-
var VALID_TRANSITIONS = {
|
|
48
|
-
idle: ["loading"],
|
|
49
|
-
loading: ["ready", "error"],
|
|
50
|
-
ready: ["playing", "paused", "error"],
|
|
51
|
-
playing: ["paused", "buffering", "ended", "error", "loading"],
|
|
52
|
-
paused: ["playing", "buffering", "ended", "error", "loading"],
|
|
53
|
-
buffering: ["playing", "paused", "error", "ended"],
|
|
54
|
-
ended: ["playing", "loading", "idle"],
|
|
55
|
-
error: ["loading", "idle"]
|
|
56
|
-
};
|
|
57
|
-
var PlayerState = class {
|
|
58
|
-
constructor(onChange) {
|
|
59
|
-
this._status = "idle";
|
|
60
|
-
this.onChange = onChange;
|
|
61
|
-
}
|
|
62
|
-
get status() {
|
|
63
|
-
return this._status;
|
|
64
|
-
}
|
|
65
|
-
transition(next) {
|
|
66
|
-
if (next === this._status) return false;
|
|
67
|
-
const allowed = VALID_TRANSITIONS[this._status];
|
|
68
|
-
if (!allowed.includes(next)) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
const prev = this._status;
|
|
72
|
-
this._status = next;
|
|
73
|
-
this.onChange?.(next, prev);
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
reset() {
|
|
77
|
-
const prev = this._status;
|
|
78
|
-
this._status = "idle";
|
|
79
|
-
if (prev !== "idle") {
|
|
80
|
-
this.onChange?.("idle", prev);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
get isPlaying() {
|
|
84
|
-
return this._status === "playing";
|
|
85
|
-
}
|
|
86
|
-
get isPaused() {
|
|
87
|
-
return this._status === "paused";
|
|
88
|
-
}
|
|
89
|
-
get isBuffering() {
|
|
90
|
-
return this._status === "buffering";
|
|
91
|
-
}
|
|
92
|
-
get isEnded() {
|
|
93
|
-
return this._status === "ended";
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
var HLSJSEngine = class {
|
|
97
|
-
constructor(auth, hlsConfigOverrides) {
|
|
98
|
-
this.hls = null;
|
|
99
|
-
this.video = null;
|
|
100
|
-
this.levels = [];
|
|
101
|
-
this.listeners = /* @__PURE__ */ new Map();
|
|
102
|
-
this.statsTimer = null;
|
|
103
|
-
this.apiKey = auth?.apiKey;
|
|
104
|
-
this.embedToken = auth?.embedToken;
|
|
105
|
-
this.hlsConfigOverrides = hlsConfigOverrides;
|
|
106
|
-
}
|
|
107
|
-
load(url, videoElement) {
|
|
108
|
-
this.destroy();
|
|
109
|
-
this.video = videoElement;
|
|
110
|
-
let sourceOrigin = null;
|
|
111
|
-
try {
|
|
112
|
-
sourceOrigin = new URL(url, window.location.href).origin;
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
const config = {
|
|
116
|
-
...HLS_DEFAULT_CONFIG,
|
|
117
|
-
...this.hlsConfigOverrides
|
|
118
|
-
};
|
|
119
|
-
if (this.apiKey || this.embedToken) {
|
|
120
|
-
const apiKey = this.apiKey;
|
|
121
|
-
const embedToken = this.embedToken;
|
|
122
|
-
config.xhrSetup = (xhr, requestUrl) => {
|
|
123
|
-
try {
|
|
124
|
-
const u = new URL(requestUrl, url);
|
|
125
|
-
const isTrustedOrigin = sourceOrigin !== null && u.origin === sourceOrigin;
|
|
126
|
-
if (apiKey && isTrustedOrigin) {
|
|
127
|
-
xhr.setRequestHeader("X-API-Key", apiKey);
|
|
128
|
-
} else if (embedToken && isTrustedOrigin) {
|
|
129
|
-
u.searchParams.set("token", embedToken);
|
|
130
|
-
xhr.open("GET", u.toString(), true);
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
this.hls = new Hls__default.default(config);
|
|
137
|
-
this.hls.on(Hls__default.default.Events.MANIFEST_PARSED, (_event, data) => {
|
|
138
|
-
this.levels = data.levels.map((level, index) => ({
|
|
139
|
-
index,
|
|
140
|
-
height: level.height,
|
|
141
|
-
width: level.width,
|
|
142
|
-
bitrate: level.bitrate,
|
|
143
|
-
label: `${level.height}p`,
|
|
144
|
-
active: index === this.hls.currentLevel
|
|
145
|
-
}));
|
|
146
|
-
this.fire("qualitylevels", this.levels);
|
|
147
|
-
});
|
|
148
|
-
this.hls.on(Hls__default.default.Events.LEVEL_SWITCHED, (_event, data) => {
|
|
149
|
-
this.levels = this.levels.map((level) => ({
|
|
150
|
-
...level,
|
|
151
|
-
active: level.index === data.level
|
|
152
|
-
}));
|
|
153
|
-
const activeLevel = this.levels[data.level];
|
|
154
|
-
if (activeLevel) {
|
|
155
|
-
this.fire("qualitychange", activeLevel);
|
|
156
|
-
}
|
|
157
|
-
this.fireStats({
|
|
158
|
-
kind: "level_switch",
|
|
159
|
-
level: data.level,
|
|
160
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
this.hls.on(Hls__default.default.Events.ERROR, (_event, data) => {
|
|
164
|
-
this.fireStats({
|
|
165
|
-
kind: "hls_error",
|
|
166
|
-
fatal: data.fatal,
|
|
167
|
-
errorType: data.type,
|
|
168
|
-
errorDetails: data.details,
|
|
169
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate
|
|
170
|
-
});
|
|
171
|
-
if (data.fatal) {
|
|
172
|
-
switch (data.type) {
|
|
173
|
-
case Hls__default.default.ErrorTypes.NETWORK_ERROR:
|
|
174
|
-
this.hls.startLoad();
|
|
175
|
-
break;
|
|
176
|
-
case Hls__default.default.ErrorTypes.MEDIA_ERROR:
|
|
177
|
-
this.hls.recoverMediaError();
|
|
178
|
-
break;
|
|
179
|
-
default:
|
|
180
|
-
this.fire("error", {
|
|
181
|
-
code: data.type,
|
|
182
|
-
message: data.details || "Fatal playback error"
|
|
183
|
-
});
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
this.hls.on(Hls__default.default.Events.FRAG_BUFFERED, (_event, data) => {
|
|
189
|
-
this.fire("buffering", false);
|
|
190
|
-
const stats = data ?? {};
|
|
191
|
-
const loadStart = stats.stats?.loading?.start ?? 0;
|
|
192
|
-
const loadEnd = stats.stats?.loading?.end ?? 0;
|
|
193
|
-
this.fireStats({
|
|
194
|
-
kind: "fragment",
|
|
195
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate,
|
|
196
|
-
fragmentDuration: stats.frag?.duration,
|
|
197
|
-
fragmentSizeBytes: stats.stats?.total,
|
|
198
|
-
fragmentLoadMs: loadEnd > loadStart ? loadEnd - loadStart : void 0
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
this.hls.on(Hls__default.default.Events.FRAG_LOADED, (_event, data) => {
|
|
202
|
-
const response = data.networkDetails;
|
|
203
|
-
if (response && typeof response.getResponseHeader === "function") {
|
|
204
|
-
this.fireStats({
|
|
205
|
-
kind: "fragment",
|
|
206
|
-
cdnNode: response.getResponseHeader("X-Amz-Cf-Pop") || response.getResponseHeader("X-Edge-Location") || void 0,
|
|
207
|
-
cdnCacheStatus: response.getResponseHeader("X-Cache") || void 0,
|
|
208
|
-
cdnRequestID: response.getResponseHeader("X-Amz-Cf-Id") || void 0,
|
|
209
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
this.hls.loadSource(url);
|
|
214
|
-
this.hls.attachMedia(videoElement);
|
|
215
|
-
this.statsTimer = setInterval(() => {
|
|
216
|
-
if (!this.video) return;
|
|
217
|
-
const v = this.video;
|
|
218
|
-
if (typeof v.getVideoPlaybackQuality === "function") {
|
|
219
|
-
const quality = v.getVideoPlaybackQuality();
|
|
220
|
-
this.fireStats({
|
|
221
|
-
kind: "periodic",
|
|
222
|
-
droppedFrames: quality.droppedVideoFrames,
|
|
223
|
-
totalFrames: quality.totalVideoFrames,
|
|
224
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate
|
|
225
|
-
});
|
|
226
|
-
} else if (typeof v.webkitDroppedFrameCount === "number") {
|
|
227
|
-
this.fireStats({
|
|
228
|
-
kind: "periodic",
|
|
229
|
-
droppedFrames: v.webkitDroppedFrameCount,
|
|
230
|
-
bandwidthEstimate: this.hls?.bandwidthEstimate
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}, 1e4);
|
|
234
|
-
}
|
|
235
|
-
destroy() {
|
|
236
|
-
if (this.statsTimer) {
|
|
237
|
-
clearInterval(this.statsTimer);
|
|
238
|
-
this.statsTimer = null;
|
|
239
|
-
}
|
|
240
|
-
if (this.hls) {
|
|
241
|
-
this.hls.destroy();
|
|
242
|
-
this.hls = null;
|
|
243
|
-
}
|
|
244
|
-
this.video = null;
|
|
245
|
-
this.levels = [];
|
|
246
|
-
}
|
|
247
|
-
getQualityLevels() {
|
|
248
|
-
return this.levels;
|
|
249
|
-
}
|
|
250
|
-
setQualityLevel(index) {
|
|
251
|
-
if (this.hls) {
|
|
252
|
-
this.hls.currentLevel = index;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
getCurrentQuality() {
|
|
256
|
-
return this.hls?.currentLevel ?? -1;
|
|
257
|
-
}
|
|
258
|
-
isAutoQuality() {
|
|
259
|
-
return this.hls?.autoLevelEnabled ?? true;
|
|
260
|
-
}
|
|
261
|
-
setAutoQuality() {
|
|
262
|
-
if (this.hls) {
|
|
263
|
-
this.hls.currentLevel = -1;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
on(event, callback) {
|
|
267
|
-
if (!this.listeners.has(event)) {
|
|
268
|
-
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
269
|
-
}
|
|
270
|
-
this.listeners.get(event).add(callback);
|
|
271
|
-
}
|
|
272
|
-
off(event, callback) {
|
|
273
|
-
this.listeners.get(event)?.delete(callback);
|
|
274
|
-
}
|
|
275
|
-
fire(event, ...args) {
|
|
276
|
-
const set = this.listeners.get(event);
|
|
277
|
-
if (!set) return;
|
|
278
|
-
for (const cb of set) {
|
|
279
|
-
cb(...args);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
fireStats(stats) {
|
|
283
|
-
this.fire("stats", { ...stats, statsSource: "hlsjs" });
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// src/engine/NativeHLSEngine.ts
|
|
288
|
-
var NativeHLSEngine = class {
|
|
289
|
-
constructor() {
|
|
290
|
-
this.video = null;
|
|
291
|
-
this.listeners = /* @__PURE__ */ new Map();
|
|
292
|
-
this.statsTimer = null;
|
|
293
|
-
this.lastResourceIndex = 0;
|
|
294
|
-
this.sourceHost = null;
|
|
295
|
-
}
|
|
296
|
-
load(url, videoElement) {
|
|
297
|
-
this.destroy();
|
|
298
|
-
this.video = videoElement;
|
|
299
|
-
try {
|
|
300
|
-
const parsed = new URL(url, window.location.href);
|
|
301
|
-
this.sourceHost = parsed.host;
|
|
302
|
-
} catch {
|
|
303
|
-
this.sourceHost = null;
|
|
304
|
-
}
|
|
305
|
-
videoElement.src = url;
|
|
306
|
-
videoElement.addEventListener("loadedmetadata", () => {
|
|
307
|
-
this.fire("qualitylevels", []);
|
|
308
|
-
});
|
|
309
|
-
videoElement.addEventListener("error", () => {
|
|
310
|
-
const err = videoElement.error;
|
|
311
|
-
this.fire("error", {
|
|
312
|
-
code: `MEDIA_ERR_${err?.code ?? 0}`,
|
|
313
|
-
message: err?.message ?? "Native playback error"
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
this.statsTimer = setInterval(() => {
|
|
317
|
-
this.scavengeStats();
|
|
318
|
-
}, 1e4);
|
|
319
|
-
}
|
|
320
|
-
destroy() {
|
|
321
|
-
if (this.statsTimer) {
|
|
322
|
-
clearInterval(this.statsTimer);
|
|
323
|
-
this.statsTimer = null;
|
|
324
|
-
}
|
|
325
|
-
if (this.video) {
|
|
326
|
-
this.video.removeAttribute("src");
|
|
327
|
-
this.video.load();
|
|
328
|
-
this.video = null;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
scavengeStats() {
|
|
332
|
-
if (!this.video) return;
|
|
333
|
-
const v = this.video;
|
|
334
|
-
let droppedFrames;
|
|
335
|
-
let totalFrames;
|
|
336
|
-
if (typeof v.getVideoPlaybackQuality === "function") {
|
|
337
|
-
const q = v.getVideoPlaybackQuality();
|
|
338
|
-
droppedFrames = q.droppedVideoFrames;
|
|
339
|
-
totalFrames = q.totalVideoFrames;
|
|
340
|
-
} else if (typeof v.webkitDroppedFrameCount === "number") {
|
|
341
|
-
droppedFrames = v.webkitDroppedFrameCount;
|
|
342
|
-
}
|
|
343
|
-
if (typeof performance !== "undefined" && typeof performance.getEntriesByType === "function") {
|
|
344
|
-
const resources = performance.getEntriesByType("resource");
|
|
345
|
-
for (let i = this.lastResourceIndex; i < resources.length; i++) {
|
|
346
|
-
const res = resources[i];
|
|
347
|
-
if (!this.isVideoFragment(res.name)) continue;
|
|
348
|
-
const isCrossOrigin = this.sourceHost !== null && !res.name.includes(window.location.host);
|
|
349
|
-
const taoAvailable = !isCrossOrigin || res.transferSize > 0 || res.responseStart > 0;
|
|
350
|
-
this.fire("stats", {
|
|
351
|
-
kind: "fragment",
|
|
352
|
-
statsSource: "native_resource_timing",
|
|
353
|
-
fragmentLoadMs: Math.round(res.duration),
|
|
354
|
-
fragmentSizeBytes: taoAvailable && res.transferSize > 0 ? res.transferSize : void 0,
|
|
355
|
-
bandwidthEstimate: taoAvailable && res.transferSize > 0 && res.duration > 0 ? Math.round(res.transferSize * 8 / (res.duration / 1e3)) : void 0,
|
|
356
|
-
taoAvailable
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
this.lastResourceIndex = resources.length;
|
|
360
|
-
}
|
|
361
|
-
this.fire("stats", {
|
|
362
|
-
kind: "periodic",
|
|
363
|
-
statsSource: "native_resource_timing",
|
|
364
|
-
droppedFrames,
|
|
365
|
-
totalFrames
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
isVideoFragment(url) {
|
|
369
|
-
if (this.sourceHost) {
|
|
370
|
-
try {
|
|
371
|
-
const parsed = new URL(url, window.location.href);
|
|
372
|
-
if (parsed.host !== this.sourceHost) return false;
|
|
373
|
-
} catch {
|
|
374
|
-
return false;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
const path = url.split("?")[0];
|
|
378
|
-
return path.endsWith(".ts") || path.endsWith(".m4s") || path.endsWith(".m4v") || path.includes("/seg-") || path.includes("/segment");
|
|
379
|
-
}
|
|
380
|
-
getQualityLevels() {
|
|
381
|
-
return [];
|
|
382
|
-
}
|
|
383
|
-
setQualityLevel(_index) {
|
|
384
|
-
}
|
|
385
|
-
getCurrentQuality() {
|
|
386
|
-
return -1;
|
|
387
|
-
}
|
|
388
|
-
isAutoQuality() {
|
|
389
|
-
return true;
|
|
390
|
-
}
|
|
391
|
-
setAutoQuality() {
|
|
392
|
-
}
|
|
393
|
-
on(event, callback) {
|
|
394
|
-
if (!this.listeners.has(event)) {
|
|
395
|
-
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
396
|
-
}
|
|
397
|
-
this.listeners.get(event).add(callback);
|
|
398
|
-
}
|
|
399
|
-
off(event, callback) {
|
|
400
|
-
this.listeners.get(event)?.delete(callback);
|
|
401
|
-
}
|
|
402
|
-
fire(event, ...args) {
|
|
403
|
-
const set = this.listeners.get(event);
|
|
404
|
-
if (!set) return;
|
|
405
|
-
for (const cb of set) {
|
|
406
|
-
cb(...args);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
// src/utils/device.ts
|
|
412
|
-
function supportsNativeHLS() {
|
|
413
|
-
if (typeof document === "undefined") return false;
|
|
414
|
-
const video = document.createElement("video");
|
|
415
|
-
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
|
|
416
|
-
}
|
|
417
|
-
function supportsFullscreen() {
|
|
418
|
-
if (typeof document === "undefined") return false;
|
|
419
|
-
const el2 = document.documentElement;
|
|
420
|
-
return !!(el2.requestFullscreen || el2.webkitRequestFullscreen);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// src/engine/engineFactory.ts
|
|
424
|
-
function createEngine(auth, hlsConfig) {
|
|
425
|
-
const authOpts = typeof auth === "string" ? { apiKey: auth } : auth ?? {};
|
|
426
|
-
if (Hls__default.default.isSupported()) {
|
|
427
|
-
return new HLSJSEngine(authOpts, hlsConfig);
|
|
428
|
-
}
|
|
429
|
-
if (supportsNativeHLS()) {
|
|
430
|
-
return new NativeHLSEngine();
|
|
431
|
-
}
|
|
432
|
-
throw new Error("HLS playback is not supported in this browser");
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// src/api/ScaleMuleClient.ts
|
|
436
|
-
var ScaleMuleClient = class {
|
|
437
|
-
constructor(optionsOrKey, baseUrl) {
|
|
438
|
-
if (typeof optionsOrKey === "string") {
|
|
439
|
-
this.apiKey = optionsOrKey;
|
|
440
|
-
this.baseUrl = (baseUrl ?? DEFAULT_CONFIG.apiBaseUrl).replace(/\/$/, "");
|
|
441
|
-
} else {
|
|
442
|
-
this.apiKey = optionsOrKey.apiKey;
|
|
443
|
-
this.embedToken = optionsOrKey.embedToken;
|
|
444
|
-
this.baseUrl = (optionsOrKey.baseUrl ?? DEFAULT_CONFIG.apiBaseUrl).replace(/\/$/, "");
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
buildUrl(path) {
|
|
448
|
-
const url = new URL(`${this.baseUrl}${path}`);
|
|
449
|
-
if (this.embedToken) {
|
|
450
|
-
url.searchParams.set("token", this.embedToken);
|
|
451
|
-
}
|
|
452
|
-
return url.toString();
|
|
453
|
-
}
|
|
454
|
-
getHeaders() {
|
|
455
|
-
const headers = { "Accept": "application/json" };
|
|
456
|
-
if (this.apiKey) {
|
|
457
|
-
headers["X-API-Key"] = this.apiKey;
|
|
458
|
-
}
|
|
459
|
-
return headers;
|
|
460
|
-
}
|
|
461
|
-
async getVideoMetadata(videoId) {
|
|
462
|
-
const path = this.embedToken ? `/v1/videos/embed/${videoId}/metadata` : `/v1/videos/${videoId}`;
|
|
463
|
-
const response = await fetch(this.buildUrl(path), {
|
|
464
|
-
headers: this.getHeaders()
|
|
465
|
-
});
|
|
466
|
-
if (!response.ok) {
|
|
467
|
-
const text = await response.text().catch(() => "");
|
|
468
|
-
throw new Error(`Failed to load video ${videoId}: ${response.status} ${text}`);
|
|
469
|
-
}
|
|
470
|
-
const data = await response.json();
|
|
471
|
-
return {
|
|
472
|
-
id: data.id,
|
|
473
|
-
title: data.title ?? "",
|
|
474
|
-
duration: data.duration ?? 0,
|
|
475
|
-
poster: data.poster_url ?? data.thumbnail_url,
|
|
476
|
-
playlistUrl: data.playlist_url,
|
|
477
|
-
qualities: (data.qualities ?? []).map((q, i) => ({
|
|
478
|
-
index: i,
|
|
479
|
-
height: q.height,
|
|
480
|
-
width: q.width,
|
|
481
|
-
bitrate: q.bitrate,
|
|
482
|
-
label: `${q.height}p`,
|
|
483
|
-
active: false
|
|
484
|
-
}))
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
async trackPlayback(videoId, payload, options) {
|
|
488
|
-
const path = this.embedToken ? `/v1/videos/embed/${videoId}/track` : `/v1/videos/${videoId}/track`;
|
|
489
|
-
const response = await fetch(this.buildUrl(path), {
|
|
490
|
-
method: "POST",
|
|
491
|
-
headers: {
|
|
492
|
-
...this.getHeaders(),
|
|
493
|
-
"Content-Type": "application/json"
|
|
494
|
-
},
|
|
495
|
-
body: JSON.stringify(payload),
|
|
496
|
-
keepalive: options?.keepalive ?? false
|
|
497
|
-
});
|
|
498
|
-
if (!response.ok) {
|
|
499
|
-
const text = await response.text().catch(() => "");
|
|
500
|
-
throw new Error(`Failed to track playback for ${videoId}: ${response.status} ${text}`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
// src/ui/IconSet.ts
|
|
506
|
-
var stroke = (d) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">${d}</svg>`;
|
|
507
|
-
var fill = (d) => `<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${d}</svg>`;
|
|
508
|
-
var Icons = {
|
|
509
|
-
play: fill('<path d="M6.906 4.537A.6.6 0 0 0 6 5.053v13.894a.6.6 0 0 0 .906.516l11.723-6.947a.6.6 0 0 0 0-1.032L6.906 4.537Z"/>'),
|
|
510
|
-
pause: fill('<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>'),
|
|
511
|
-
volumeHigh: stroke('<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>'),
|
|
512
|
-
volumeLow: stroke('<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>'),
|
|
513
|
-
volumeMuted: stroke('<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>'),
|
|
514
|
-
fullscreen: stroke('<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>'),
|
|
515
|
-
fullscreenExit: stroke('<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>'),
|
|
516
|
-
settings: stroke('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>'),
|
|
517
|
-
chevronLeft: stroke('<polyline points="15 18 9 12 15 6"/>'),
|
|
518
|
-
check: stroke('<polyline points="20 6 9 17 4 12"/>')
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
// src/utils/dom.ts
|
|
522
|
-
function el(tag, attrs, children) {
|
|
523
|
-
const element = document.createElement(tag);
|
|
524
|
-
if (attrs) {
|
|
525
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
526
|
-
element.setAttribute(key, value);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
return element;
|
|
530
|
-
}
|
|
531
|
-
function svgIcon(svgContent, className) {
|
|
532
|
-
const wrapper = document.createElement("span");
|
|
533
|
-
wrapper.className = "gallop-icon";
|
|
534
|
-
wrapper.innerHTML = svgContent;
|
|
535
|
-
wrapper.setAttribute("aria-hidden", "true");
|
|
536
|
-
return wrapper;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// src/ui/ProgressBar.ts
|
|
540
|
-
var ProgressBar = class {
|
|
541
|
-
constructor(player) {
|
|
542
|
-
this.dragging = false;
|
|
543
|
-
this.onMouseDown = (e) => {
|
|
544
|
-
e.preventDefault();
|
|
545
|
-
this.dragging = true;
|
|
546
|
-
this.seekToPosition(e.clientX);
|
|
547
|
-
const onMove = (ev) => this.seekToPosition(ev.clientX);
|
|
548
|
-
const onUp = () => {
|
|
549
|
-
this.dragging = false;
|
|
550
|
-
document.removeEventListener("mousemove", onMove);
|
|
551
|
-
document.removeEventListener("mouseup", onUp);
|
|
552
|
-
};
|
|
553
|
-
document.addEventListener("mousemove", onMove);
|
|
554
|
-
document.addEventListener("mouseup", onUp);
|
|
555
|
-
};
|
|
556
|
-
this.onTouchStart = (e) => {
|
|
557
|
-
e.preventDefault();
|
|
558
|
-
this.dragging = true;
|
|
559
|
-
const touch = e.touches[0];
|
|
560
|
-
this.seekToPosition(touch.clientX);
|
|
561
|
-
const onMove = (ev) => this.seekToPosition(ev.touches[0].clientX);
|
|
562
|
-
const onEnd = () => {
|
|
563
|
-
this.dragging = false;
|
|
564
|
-
document.removeEventListener("touchmove", onMove);
|
|
565
|
-
document.removeEventListener("touchend", onEnd);
|
|
566
|
-
};
|
|
567
|
-
document.addEventListener("touchmove", onMove);
|
|
568
|
-
document.addEventListener("touchend", onEnd);
|
|
569
|
-
};
|
|
570
|
-
this.player = player;
|
|
571
|
-
this.element = document.createElement("div");
|
|
572
|
-
this.element.className = "gallop-progress-container";
|
|
573
|
-
this.bar = document.createElement("div");
|
|
574
|
-
this.bar.className = "gallop-progress-bar";
|
|
575
|
-
this.buffered = document.createElement("div");
|
|
576
|
-
this.buffered.className = "gallop-progress-buffered";
|
|
577
|
-
this.played = document.createElement("div");
|
|
578
|
-
this.played.className = "gallop-progress-played";
|
|
579
|
-
this.thumb = document.createElement("div");
|
|
580
|
-
this.thumb.className = "gallop-progress-thumb";
|
|
581
|
-
this.bar.appendChild(this.buffered);
|
|
582
|
-
this.bar.appendChild(this.played);
|
|
583
|
-
this.bar.appendChild(this.thumb);
|
|
584
|
-
this.element.appendChild(this.bar);
|
|
585
|
-
this.element.addEventListener("mousedown", this.onMouseDown);
|
|
586
|
-
this.element.addEventListener("touchstart", this.onTouchStart, { passive: false });
|
|
587
|
-
}
|
|
588
|
-
update(currentTime, duration, bufferedRanges) {
|
|
589
|
-
if (this.dragging || !duration) return;
|
|
590
|
-
const pct = currentTime / duration * 100;
|
|
591
|
-
this.played.style.width = `${pct}%`;
|
|
592
|
-
this.thumb.style.left = `${pct}%`;
|
|
593
|
-
if (bufferedRanges.length > 0) {
|
|
594
|
-
const bufferedEnd = bufferedRanges.end(bufferedRanges.length - 1);
|
|
595
|
-
this.buffered.style.width = `${bufferedEnd / duration * 100}%`;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
seekToPosition(clientX) {
|
|
599
|
-
const rect = this.bar.getBoundingClientRect();
|
|
600
|
-
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
601
|
-
const time = pct * this.player.duration;
|
|
602
|
-
this.player.seek(time);
|
|
603
|
-
this.played.style.width = `${pct * 100}%`;
|
|
604
|
-
this.thumb.style.left = `${pct * 100}%`;
|
|
605
|
-
}
|
|
606
|
-
destroy() {
|
|
607
|
-
this.element.removeEventListener("mousedown", this.onMouseDown);
|
|
608
|
-
this.element.removeEventListener("touchstart", this.onTouchStart);
|
|
609
|
-
}
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
// src/ui/VolumeControl.ts
|
|
613
|
-
var VolumeControl = class {
|
|
614
|
-
constructor(player) {
|
|
615
|
-
this.onSliderMouseDown = (e) => {
|
|
616
|
-
e.preventDefault();
|
|
617
|
-
e.stopPropagation();
|
|
618
|
-
this.setVolumeFromX(e.clientX);
|
|
619
|
-
const onMove = (ev) => this.setVolumeFromX(ev.clientX);
|
|
620
|
-
const onUp = () => {
|
|
621
|
-
document.removeEventListener("mousemove", onMove);
|
|
622
|
-
document.removeEventListener("mouseup", onUp);
|
|
623
|
-
};
|
|
624
|
-
document.addEventListener("mousemove", onMove);
|
|
625
|
-
document.addEventListener("mouseup", onUp);
|
|
626
|
-
};
|
|
627
|
-
this.player = player;
|
|
628
|
-
this.element = document.createElement("div");
|
|
629
|
-
this.element.className = "gallop-volume";
|
|
630
|
-
this.muteBtn = document.createElement("button");
|
|
631
|
-
this.muteBtn.className = "gallop-btn";
|
|
632
|
-
this.muteBtn.setAttribute("aria-label", "Mute");
|
|
633
|
-
this.iconHigh = svgIcon(Icons.volumeHigh);
|
|
634
|
-
this.iconLow = svgIcon(Icons.volumeLow);
|
|
635
|
-
this.iconMuted = svgIcon(Icons.volumeMuted);
|
|
636
|
-
this.muteBtn.appendChild(this.iconHigh);
|
|
637
|
-
this.muteBtn.addEventListener("click", (e) => {
|
|
638
|
-
e.stopPropagation();
|
|
639
|
-
player.toggleMute();
|
|
640
|
-
});
|
|
641
|
-
this.sliderWrap = document.createElement("div");
|
|
642
|
-
this.sliderWrap.className = "gallop-volume-slider-wrap";
|
|
643
|
-
this.slider = document.createElement("div");
|
|
644
|
-
this.slider.className = "gallop-volume-slider";
|
|
645
|
-
this.fill = document.createElement("div");
|
|
646
|
-
this.fill.className = "gallop-volume-fill";
|
|
647
|
-
this.fill.style.width = `${player.volume * 100}%`;
|
|
648
|
-
this.slider.appendChild(this.fill);
|
|
649
|
-
this.sliderWrap.appendChild(this.slider);
|
|
650
|
-
this.element.appendChild(this.muteBtn);
|
|
651
|
-
this.element.appendChild(this.sliderWrap);
|
|
652
|
-
this.slider.addEventListener("mousedown", this.onSliderMouseDown);
|
|
653
|
-
}
|
|
654
|
-
update(volume, muted) {
|
|
655
|
-
const effectiveVolume = muted ? 0 : volume;
|
|
656
|
-
this.fill.style.width = `${effectiveVolume * 100}%`;
|
|
657
|
-
this.muteBtn.innerHTML = "";
|
|
658
|
-
if (muted || volume === 0) {
|
|
659
|
-
this.muteBtn.appendChild(this.iconMuted.cloneNode(true));
|
|
660
|
-
} else if (volume < 0.5) {
|
|
661
|
-
this.muteBtn.appendChild(this.iconLow.cloneNode(true));
|
|
662
|
-
} else {
|
|
663
|
-
this.muteBtn.appendChild(this.iconHigh.cloneNode(true));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
setVolumeFromX(clientX) {
|
|
667
|
-
const rect = this.slider.getBoundingClientRect();
|
|
668
|
-
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
669
|
-
this.player.volume = pct;
|
|
670
|
-
if (this.player.muted && pct > 0) {
|
|
671
|
-
this.player.muted = false;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
destroy() {
|
|
675
|
-
this.slider.removeEventListener("mousedown", this.onSliderMouseDown);
|
|
676
|
-
}
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
// src/utils/time.ts
|
|
680
|
-
function formatTime(seconds) {
|
|
681
|
-
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
|
682
|
-
const h = Math.floor(seconds / 3600);
|
|
683
|
-
const m = Math.floor(seconds % 3600 / 60);
|
|
684
|
-
const s = Math.floor(seconds % 60);
|
|
685
|
-
if (h > 0) {
|
|
686
|
-
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
|
687
|
-
}
|
|
688
|
-
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
689
|
-
}
|
|
690
|
-
function clamp(value, min, max) {
|
|
691
|
-
return Math.min(Math.max(value, min), max);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// src/ui/TimeDisplay.ts
|
|
695
|
-
var TimeDisplay = class {
|
|
696
|
-
constructor() {
|
|
697
|
-
this.currentTime = 0;
|
|
698
|
-
this.duration = 0;
|
|
699
|
-
this.element = document.createElement("span");
|
|
700
|
-
this.element.className = "gallop-time";
|
|
701
|
-
this.render();
|
|
702
|
-
}
|
|
703
|
-
update(currentTime, duration) {
|
|
704
|
-
this.currentTime = currentTime;
|
|
705
|
-
this.duration = duration;
|
|
706
|
-
this.render();
|
|
707
|
-
}
|
|
708
|
-
render() {
|
|
709
|
-
this.element.textContent = `${formatTime(this.currentTime)} / ${formatTime(this.duration)}`;
|
|
710
|
-
}
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
// src/ui/SettingsMenu.ts
|
|
714
|
-
var SettingsMenu = class {
|
|
715
|
-
constructor(player) {
|
|
716
|
-
this.currentView = "main";
|
|
717
|
-
this.qualityLevels = [];
|
|
718
|
-
this.player = player;
|
|
719
|
-
this.button = document.createElement("button");
|
|
720
|
-
this.button.className = "gallop-btn";
|
|
721
|
-
this.button.setAttribute("aria-label", "Settings");
|
|
722
|
-
this.button.appendChild(svgIcon(Icons.settings));
|
|
723
|
-
this.button.addEventListener("click", (e) => {
|
|
724
|
-
e.stopPropagation();
|
|
725
|
-
this.toggle();
|
|
726
|
-
});
|
|
727
|
-
this.element = document.createElement("div");
|
|
728
|
-
this.element.className = "gallop-settings-menu";
|
|
729
|
-
this.element.hidden = true;
|
|
730
|
-
this.element.addEventListener("click", (e) => e.stopPropagation());
|
|
731
|
-
this.renderMain();
|
|
732
|
-
}
|
|
733
|
-
setQualityLevels(levels) {
|
|
734
|
-
this.qualityLevels = levels;
|
|
735
|
-
if (this.currentView === "quality") {
|
|
736
|
-
this.renderQuality();
|
|
737
|
-
} else if (this.currentView === "main") {
|
|
738
|
-
this.renderMain();
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
toggle() {
|
|
742
|
-
if (this.element.hidden) {
|
|
743
|
-
this.currentView = "main";
|
|
744
|
-
this.renderMain();
|
|
745
|
-
this.element.hidden = false;
|
|
746
|
-
} else {
|
|
747
|
-
this.element.hidden = true;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
close() {
|
|
751
|
-
this.element.hidden = true;
|
|
752
|
-
this.currentView = "main";
|
|
753
|
-
}
|
|
754
|
-
renderMain() {
|
|
755
|
-
this.element.innerHTML = "";
|
|
756
|
-
const qualityItem = this.createItem("Quality", this.getQualityLabel());
|
|
757
|
-
qualityItem.addEventListener("click", () => {
|
|
758
|
-
this.currentView = "quality";
|
|
759
|
-
this.renderQuality();
|
|
760
|
-
});
|
|
761
|
-
const speedItem = this.createItem("Speed", this.getSpeedLabel());
|
|
762
|
-
speedItem.addEventListener("click", () => {
|
|
763
|
-
this.currentView = "speed";
|
|
764
|
-
this.renderSpeed();
|
|
765
|
-
});
|
|
766
|
-
this.element.appendChild(qualityItem);
|
|
767
|
-
this.element.appendChild(speedItem);
|
|
768
|
-
}
|
|
769
|
-
renderQuality() {
|
|
770
|
-
this.element.innerHTML = "";
|
|
771
|
-
const header = this.createHeader("Quality");
|
|
772
|
-
this.element.appendChild(header);
|
|
773
|
-
const autoItem = this.createSelectItem(
|
|
774
|
-
"Auto",
|
|
775
|
-
this.player.isAutoQuality()
|
|
776
|
-
);
|
|
777
|
-
autoItem.addEventListener("click", () => {
|
|
778
|
-
this.player.setAutoQuality();
|
|
779
|
-
this.close();
|
|
780
|
-
});
|
|
781
|
-
this.element.appendChild(autoItem);
|
|
782
|
-
const sorted = [...this.qualityLevels].sort((a, b) => b.height - a.height);
|
|
783
|
-
for (const level of sorted) {
|
|
784
|
-
const item = this.createSelectItem(
|
|
785
|
-
level.label,
|
|
786
|
-
!this.player.isAutoQuality() && level.index === this.player.getCurrentQuality()
|
|
787
|
-
);
|
|
788
|
-
item.addEventListener("click", () => {
|
|
789
|
-
this.player.setQualityLevel(level.index);
|
|
790
|
-
this.close();
|
|
791
|
-
});
|
|
792
|
-
this.element.appendChild(item);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
renderSpeed() {
|
|
796
|
-
this.element.innerHTML = "";
|
|
797
|
-
const header = this.createHeader("Speed");
|
|
798
|
-
this.element.appendChild(header);
|
|
799
|
-
for (const speed of SPEED_PRESETS) {
|
|
800
|
-
const label = speed === 1 ? "Normal" : `${speed}x`;
|
|
801
|
-
const item = this.createSelectItem(label, this.player.playbackRate === speed);
|
|
802
|
-
item.addEventListener("click", () => {
|
|
803
|
-
this.player.playbackRate = speed;
|
|
804
|
-
this.close();
|
|
805
|
-
});
|
|
806
|
-
this.element.appendChild(item);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
createItem(label, value) {
|
|
810
|
-
const item = document.createElement("div");
|
|
811
|
-
item.className = "gallop-settings-item";
|
|
812
|
-
const labelEl = document.createElement("span");
|
|
813
|
-
labelEl.textContent = label;
|
|
814
|
-
const valueEl = document.createElement("span");
|
|
815
|
-
valueEl.className = "gallop-settings-value";
|
|
816
|
-
valueEl.textContent = value;
|
|
817
|
-
item.appendChild(labelEl);
|
|
818
|
-
item.appendChild(valueEl);
|
|
819
|
-
return item;
|
|
820
|
-
}
|
|
821
|
-
createSelectItem(label, active) {
|
|
822
|
-
const item = document.createElement("div");
|
|
823
|
-
item.className = "gallop-settings-item" + (active ? " gallop-settings-item-active" : "");
|
|
824
|
-
if (active) {
|
|
825
|
-
item.appendChild(svgIcon(Icons.check));
|
|
826
|
-
} else {
|
|
827
|
-
const spacer = document.createElement("span");
|
|
828
|
-
spacer.className = "gallop-icon";
|
|
829
|
-
item.appendChild(spacer);
|
|
830
|
-
}
|
|
831
|
-
const labelEl = document.createElement("span");
|
|
832
|
-
labelEl.textContent = label;
|
|
833
|
-
item.appendChild(labelEl);
|
|
834
|
-
return item;
|
|
835
|
-
}
|
|
836
|
-
createHeader(title) {
|
|
837
|
-
const header = document.createElement("div");
|
|
838
|
-
header.className = "gallop-settings-header";
|
|
839
|
-
header.appendChild(svgIcon(Icons.chevronLeft));
|
|
840
|
-
const text = document.createElement("span");
|
|
841
|
-
text.textContent = title;
|
|
842
|
-
header.appendChild(text);
|
|
843
|
-
header.addEventListener("click", () => {
|
|
844
|
-
this.currentView = "main";
|
|
845
|
-
this.renderMain();
|
|
846
|
-
});
|
|
847
|
-
return header;
|
|
848
|
-
}
|
|
849
|
-
getQualityLabel() {
|
|
850
|
-
if (this.player.isAutoQuality()) {
|
|
851
|
-
const current2 = this.player.getCurrentQuality();
|
|
852
|
-
const level2 = this.qualityLevels.find((l) => l.index === current2);
|
|
853
|
-
return level2 ? `Auto (${level2.height}p)` : "Auto";
|
|
854
|
-
}
|
|
855
|
-
const current = this.player.getCurrentQuality();
|
|
856
|
-
const level = this.qualityLevels.find((l) => l.index === current);
|
|
857
|
-
return level?.label ?? "Auto";
|
|
858
|
-
}
|
|
859
|
-
getSpeedLabel() {
|
|
860
|
-
const rate = this.player.playbackRate;
|
|
861
|
-
return rate === 1 ? "Normal" : `${rate}x`;
|
|
862
|
-
}
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
// src/ui/Controls.ts
|
|
866
|
-
var Controls = class {
|
|
867
|
-
constructor(player, wrapper) {
|
|
868
|
-
this.fullscreenBtn = null;
|
|
869
|
-
this.hideTimer = null;
|
|
870
|
-
this.onActivity = () => {
|
|
871
|
-
this.showControls();
|
|
872
|
-
this.startHideTimer();
|
|
873
|
-
};
|
|
874
|
-
this.player = player;
|
|
875
|
-
this.wrapper = wrapper;
|
|
876
|
-
this.element = document.createElement("div");
|
|
877
|
-
this.element.className = "gallop-controls";
|
|
878
|
-
this.playIcon = svgIcon(Icons.play);
|
|
879
|
-
this.pauseIcon = svgIcon(Icons.pause);
|
|
880
|
-
this.fsEnterIcon = svgIcon(Icons.fullscreen);
|
|
881
|
-
this.fsExitIcon = svgIcon(Icons.fullscreenExit);
|
|
882
|
-
this.progressBar = new ProgressBar(player);
|
|
883
|
-
const row = document.createElement("div");
|
|
884
|
-
row.className = "gallop-controls-row";
|
|
885
|
-
this.playPauseBtn = document.createElement("button");
|
|
886
|
-
this.playPauseBtn.className = "gallop-btn";
|
|
887
|
-
this.playPauseBtn.setAttribute("aria-label", "Play");
|
|
888
|
-
this.playPauseBtn.appendChild(this.playIcon.cloneNode(true));
|
|
889
|
-
this.playPauseBtn.addEventListener("click", (e) => {
|
|
890
|
-
e.stopPropagation();
|
|
891
|
-
player.togglePlay();
|
|
892
|
-
});
|
|
893
|
-
row.appendChild(this.playPauseBtn);
|
|
894
|
-
this.timeDisplay = new TimeDisplay();
|
|
895
|
-
row.appendChild(this.timeDisplay.element);
|
|
896
|
-
row.appendChild(this.progressBar.element);
|
|
897
|
-
this.volumeControl = new VolumeControl(player);
|
|
898
|
-
row.appendChild(this.volumeControl.element);
|
|
899
|
-
this.settingsMenu = new SettingsMenu(player);
|
|
900
|
-
const settingsWrap = document.createElement("div");
|
|
901
|
-
settingsWrap.style.position = "relative";
|
|
902
|
-
settingsWrap.appendChild(this.settingsMenu.element);
|
|
903
|
-
settingsWrap.appendChild(this.settingsMenu.button);
|
|
904
|
-
row.appendChild(settingsWrap);
|
|
905
|
-
if (supportsFullscreen()) {
|
|
906
|
-
this.fullscreenBtn = document.createElement("button");
|
|
907
|
-
this.fullscreenBtn.className = "gallop-btn";
|
|
908
|
-
this.fullscreenBtn.setAttribute("aria-label", "Fullscreen");
|
|
909
|
-
this.fullscreenBtn.appendChild(this.fsEnterIcon.cloneNode(true));
|
|
910
|
-
this.fullscreenBtn.addEventListener("click", (e) => {
|
|
911
|
-
e.stopPropagation();
|
|
912
|
-
player.toggleFullscreen();
|
|
913
|
-
});
|
|
914
|
-
row.appendChild(this.fullscreenBtn);
|
|
915
|
-
}
|
|
916
|
-
this.element.appendChild(row);
|
|
917
|
-
this.bindEvents();
|
|
918
|
-
this.startHideTimer();
|
|
919
|
-
}
|
|
920
|
-
bindEvents() {
|
|
921
|
-
this.player.on("timeupdate", ({ currentTime, duration }) => {
|
|
922
|
-
this.timeDisplay.update(currentTime, duration);
|
|
923
|
-
this.progressBar.update(currentTime, duration, this.player.buffered);
|
|
924
|
-
});
|
|
925
|
-
this.player.on("volumechange", ({ volume, muted }) => {
|
|
926
|
-
this.volumeControl.update(volume, muted);
|
|
927
|
-
});
|
|
928
|
-
this.player.on("play", () => this.updatePlayPause(true));
|
|
929
|
-
this.player.on("pause", () => this.updatePlayPause(false));
|
|
930
|
-
this.player.on("ended", () => this.updatePlayPause(false));
|
|
931
|
-
this.player.on("qualitylevels", ({ levels }) => {
|
|
932
|
-
this.settingsMenu.setQualityLevels(levels);
|
|
933
|
-
});
|
|
934
|
-
this.player.on("fullscreenchange", ({ isFullscreen }) => {
|
|
935
|
-
if (this.fullscreenBtn) {
|
|
936
|
-
this.fullscreenBtn.innerHTML = "";
|
|
937
|
-
this.fullscreenBtn.appendChild(
|
|
938
|
-
isFullscreen ? this.fsExitIcon.cloneNode(true) : this.fsEnterIcon.cloneNode(true)
|
|
939
|
-
);
|
|
940
|
-
}
|
|
941
|
-
});
|
|
942
|
-
this.wrapper.addEventListener("mousemove", this.onActivity);
|
|
943
|
-
this.wrapper.addEventListener("mouseenter", this.onActivity);
|
|
944
|
-
this.wrapper.addEventListener("mouseleave", () => this.hideControls());
|
|
945
|
-
this.wrapper.addEventListener("click", (e) => {
|
|
946
|
-
if (e.target === this.player.getVideoElement() || e.target === this.wrapper) {
|
|
947
|
-
this.player.togglePlay();
|
|
948
|
-
this.settingsMenu.close();
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
updatePlayPause(playing) {
|
|
953
|
-
this.playPauseBtn.innerHTML = "";
|
|
954
|
-
this.playPauseBtn.appendChild(
|
|
955
|
-
playing ? this.pauseIcon.cloneNode(true) : this.playIcon.cloneNode(true)
|
|
956
|
-
);
|
|
957
|
-
this.playPauseBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
|
|
958
|
-
}
|
|
959
|
-
showControls() {
|
|
960
|
-
this.element.classList.remove("gallop-controls-hidden");
|
|
961
|
-
this.wrapper.style.cursor = "";
|
|
962
|
-
}
|
|
963
|
-
hideControls() {
|
|
964
|
-
if (this.hideTimer) {
|
|
965
|
-
clearTimeout(this.hideTimer);
|
|
966
|
-
this.hideTimer = null;
|
|
967
|
-
}
|
|
968
|
-
this.element.classList.add("gallop-controls-hidden");
|
|
969
|
-
this.wrapper.style.cursor = "none";
|
|
970
|
-
}
|
|
971
|
-
startHideTimer() {
|
|
972
|
-
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
973
|
-
this.hideTimer = setTimeout(() => this.hideControls(), CONTROLS_HIDE_DELAY);
|
|
974
|
-
}
|
|
975
|
-
destroy() {
|
|
976
|
-
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
977
|
-
this.progressBar.destroy();
|
|
978
|
-
this.volumeControl.destroy();
|
|
979
|
-
this.wrapper.removeEventListener("mousemove", this.onActivity);
|
|
980
|
-
}
|
|
981
|
-
};
|
|
982
|
-
|
|
983
|
-
// src/ui/BigPlayButton.ts
|
|
984
|
-
var BigPlayButton = class {
|
|
985
|
-
constructor(onClick) {
|
|
986
|
-
this.element = document.createElement("button");
|
|
987
|
-
this.element.className = "gallop-big-play";
|
|
988
|
-
this.element.setAttribute("aria-label", "Play");
|
|
989
|
-
this.element.appendChild(svgIcon(Icons.play));
|
|
990
|
-
this.element.addEventListener("click", (e) => {
|
|
991
|
-
e.stopPropagation();
|
|
992
|
-
onClick();
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
setVisible(visible) {
|
|
996
|
-
this.element.hidden = !visible;
|
|
997
|
-
}
|
|
998
|
-
};
|
|
999
|
-
|
|
1000
|
-
// src/ui/LoadingSpinner.ts
|
|
1001
|
-
var LoadingSpinner = class {
|
|
1002
|
-
constructor() {
|
|
1003
|
-
this.element = document.createElement("div");
|
|
1004
|
-
this.element.className = "gallop-spinner";
|
|
1005
|
-
this.element.hidden = true;
|
|
1006
|
-
this.element.setAttribute("role", "status");
|
|
1007
|
-
this.element.setAttribute("aria-label", "Loading");
|
|
1008
|
-
const ring = document.createElement("div");
|
|
1009
|
-
ring.className = "gallop-spinner-ring";
|
|
1010
|
-
this.element.appendChild(ring);
|
|
1011
|
-
}
|
|
1012
|
-
setVisible(visible) {
|
|
1013
|
-
this.element.hidden = !visible;
|
|
1014
|
-
}
|
|
1015
|
-
};
|
|
1016
|
-
|
|
1017
|
-
// src/ui/ErrorOverlay.ts
|
|
1018
|
-
var ErrorOverlay = class {
|
|
1019
|
-
constructor(onRetry) {
|
|
1020
|
-
this.element = document.createElement("div");
|
|
1021
|
-
this.element.className = "gallop-error";
|
|
1022
|
-
this.element.hidden = true;
|
|
1023
|
-
this.messageEl = document.createElement("div");
|
|
1024
|
-
this.messageEl.className = "gallop-error-message";
|
|
1025
|
-
this.messageEl.textContent = "An error occurred during playback.";
|
|
1026
|
-
this.element.appendChild(this.messageEl);
|
|
1027
|
-
const retryBtn = document.createElement("button");
|
|
1028
|
-
retryBtn.className = "gallop-error-retry";
|
|
1029
|
-
retryBtn.textContent = "Retry";
|
|
1030
|
-
retryBtn.addEventListener("click", (e) => {
|
|
1031
|
-
e.stopPropagation();
|
|
1032
|
-
onRetry();
|
|
1033
|
-
});
|
|
1034
|
-
this.element.appendChild(retryBtn);
|
|
1035
|
-
}
|
|
1036
|
-
setVisible(visible) {
|
|
1037
|
-
this.element.hidden = !visible;
|
|
1038
|
-
}
|
|
1039
|
-
setMessage(message) {
|
|
1040
|
-
this.messageEl.textContent = message;
|
|
1041
|
-
}
|
|
1042
|
-
};
|
|
1043
|
-
|
|
1044
|
-
// src/ui/PosterImage.ts
|
|
1045
|
-
var PosterImage = class {
|
|
1046
|
-
constructor() {
|
|
1047
|
-
this.element = document.createElement("div");
|
|
1048
|
-
this.element.className = "gallop-poster";
|
|
1049
|
-
this.element.hidden = true;
|
|
1050
|
-
}
|
|
1051
|
-
show(url) {
|
|
1052
|
-
this.element.style.backgroundImage = `url("${url.replace(/"/g, '\\"')}")`;
|
|
1053
|
-
this.element.hidden = false;
|
|
1054
|
-
}
|
|
1055
|
-
hide() {
|
|
1056
|
-
this.element.hidden = true;
|
|
1057
|
-
}
|
|
1058
|
-
};
|
|
1059
|
-
|
|
1060
|
-
// src/ui/ContextMenu.ts
|
|
1061
|
-
var ContextMenu = class {
|
|
1062
|
-
constructor(wrapper, pageUrl) {
|
|
1063
|
-
this.wrapper = wrapper;
|
|
1064
|
-
this.onHide = null;
|
|
1065
|
-
this.handleContextMenu = (e) => {
|
|
1066
|
-
e.preventDefault();
|
|
1067
|
-
e.stopPropagation();
|
|
1068
|
-
const rect = this.wrapper.getBoundingClientRect();
|
|
1069
|
-
let x = e.clientX - rect.left;
|
|
1070
|
-
let y = e.clientY - rect.top;
|
|
1071
|
-
this.element.hidden = false;
|
|
1072
|
-
const menuRect = this.element.getBoundingClientRect();
|
|
1073
|
-
if (x + menuRect.width > rect.width) {
|
|
1074
|
-
x = rect.width - menuRect.width - 4;
|
|
1075
|
-
}
|
|
1076
|
-
if (y + menuRect.height > rect.height) {
|
|
1077
|
-
y = rect.height - menuRect.height - 4;
|
|
1078
|
-
}
|
|
1079
|
-
this.element.style.left = `${Math.max(4, x)}px`;
|
|
1080
|
-
this.element.style.top = `${Math.max(4, y)}px`;
|
|
1081
|
-
};
|
|
1082
|
-
this.handleDocumentClick = () => {
|
|
1083
|
-
this.hide();
|
|
1084
|
-
};
|
|
1085
|
-
this.handleDocumentContext = (e) => {
|
|
1086
|
-
if (!this.wrapper.contains(e.target)) {
|
|
1087
|
-
this.hide();
|
|
1088
|
-
}
|
|
1089
|
-
};
|
|
1090
|
-
const resolvedUrl = pageUrl || window.location.href;
|
|
1091
|
-
this.items = [
|
|
1092
|
-
{
|
|
1093
|
-
label: "About ScaleMule Gallop",
|
|
1094
|
-
action: () => {
|
|
1095
|
-
window.open("https://www.scalemule.com/gallop", "_blank", "noopener");
|
|
1096
|
-
}
|
|
1097
|
-
},
|
|
1098
|
-
{
|
|
1099
|
-
label: "Report a problem",
|
|
1100
|
-
action: () => {
|
|
1101
|
-
const encoded = encodeURIComponent(resolvedUrl);
|
|
1102
|
-
window.open(
|
|
1103
|
-
`https://www.scalemule.com/gallop/report?url=${encoded}`,
|
|
1104
|
-
"_blank",
|
|
1105
|
-
"noopener"
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
},
|
|
1109
|
-
{
|
|
1110
|
-
label: "Copy link",
|
|
1111
|
-
action: () => {
|
|
1112
|
-
navigator.clipboard?.writeText(resolvedUrl).catch(() => {
|
|
1113
|
-
const input = document.createElement("input");
|
|
1114
|
-
input.value = resolvedUrl;
|
|
1115
|
-
document.body.appendChild(input);
|
|
1116
|
-
input.select();
|
|
1117
|
-
document.execCommand("copy");
|
|
1118
|
-
document.body.removeChild(input);
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
];
|
|
1123
|
-
this.element = el("div", { class: "gallop-context-menu" });
|
|
1124
|
-
this.element.hidden = true;
|
|
1125
|
-
for (const item of this.items) {
|
|
1126
|
-
const row = el("div", { class: "gallop-context-menu-item" });
|
|
1127
|
-
row.textContent = item.label;
|
|
1128
|
-
row.addEventListener("click", (e) => {
|
|
1129
|
-
e.stopPropagation();
|
|
1130
|
-
item.action();
|
|
1131
|
-
this.hide();
|
|
1132
|
-
});
|
|
1133
|
-
this.element.appendChild(row);
|
|
1134
|
-
}
|
|
1135
|
-
this.wrapper.addEventListener("contextmenu", this.handleContextMenu);
|
|
1136
|
-
document.addEventListener("click", this.handleDocumentClick);
|
|
1137
|
-
document.addEventListener("contextmenu", this.handleDocumentContext);
|
|
1138
|
-
}
|
|
1139
|
-
hide() {
|
|
1140
|
-
this.element.hidden = true;
|
|
1141
|
-
}
|
|
1142
|
-
destroy() {
|
|
1143
|
-
this.wrapper.removeEventListener("contextmenu", this.handleContextMenu);
|
|
1144
|
-
document.removeEventListener("click", this.handleDocumentClick);
|
|
1145
|
-
document.removeEventListener("contextmenu", this.handleDocumentContext);
|
|
1146
|
-
this.element.remove();
|
|
1147
|
-
}
|
|
1148
|
-
};
|
|
1149
|
-
|
|
1150
|
-
// src/theme/ThemeManager.ts
|
|
1151
|
-
var PROP_MAP = {
|
|
1152
|
-
colorPrimary: "--gallop-color-primary",
|
|
1153
|
-
colorSecondary: "--gallop-color-secondary",
|
|
1154
|
-
colorText: "--gallop-color-text",
|
|
1155
|
-
colorBackground: "--gallop-color-background",
|
|
1156
|
-
colorBuffered: "--gallop-color-buffered",
|
|
1157
|
-
colorProgress: "--gallop-color-progress",
|
|
1158
|
-
controlBarBackground: "--gallop-control-bar-bg",
|
|
1159
|
-
controlBarHeight: "--gallop-control-bar-height",
|
|
1160
|
-
borderRadius: "--gallop-border-radius",
|
|
1161
|
-
fontFamily: "--gallop-font-family",
|
|
1162
|
-
fontSize: "--gallop-font-size",
|
|
1163
|
-
iconSize: "--gallop-icon-size"
|
|
1164
|
-
};
|
|
1165
|
-
var ThemeManager = class {
|
|
1166
|
-
constructor(theme) {
|
|
1167
|
-
this.theme = theme;
|
|
1168
|
-
}
|
|
1169
|
-
apply(element) {
|
|
1170
|
-
for (const [key, cssVar] of Object.entries(PROP_MAP)) {
|
|
1171
|
-
const value = this.theme[key];
|
|
1172
|
-
if (value) {
|
|
1173
|
-
element.style.setProperty(cssVar, value);
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
update(overrides, element) {
|
|
1178
|
-
this.theme = { ...this.theme, ...overrides };
|
|
1179
|
-
this.apply(element);
|
|
1180
|
-
}
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
// src/input/KeyboardManager.ts
|
|
1184
|
-
var KeyboardManager = class {
|
|
1185
|
-
constructor(player) {
|
|
1186
|
-
this.player = player;
|
|
1187
|
-
this.handler = (e) => {
|
|
1188
|
-
const wrapper = player.getWrapperElement();
|
|
1189
|
-
if (!wrapper.contains(document.activeElement) && document.activeElement !== wrapper) {
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
const tag = e.target?.tagName;
|
|
1193
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
1194
|
-
switch (e.key) {
|
|
1195
|
-
case " ":
|
|
1196
|
-
case "k":
|
|
1197
|
-
e.preventDefault();
|
|
1198
|
-
player.togglePlay();
|
|
1199
|
-
break;
|
|
1200
|
-
case "ArrowLeft":
|
|
1201
|
-
e.preventDefault();
|
|
1202
|
-
player.seekBackward(SEEK_STEP);
|
|
1203
|
-
break;
|
|
1204
|
-
case "ArrowRight":
|
|
1205
|
-
e.preventDefault();
|
|
1206
|
-
player.seekForward(SEEK_STEP);
|
|
1207
|
-
break;
|
|
1208
|
-
case "j":
|
|
1209
|
-
e.preventDefault();
|
|
1210
|
-
player.seekBackward(SEEK_STEP_LARGE);
|
|
1211
|
-
break;
|
|
1212
|
-
case "l":
|
|
1213
|
-
e.preventDefault();
|
|
1214
|
-
player.seekForward(SEEK_STEP_LARGE);
|
|
1215
|
-
break;
|
|
1216
|
-
case "ArrowUp":
|
|
1217
|
-
e.preventDefault();
|
|
1218
|
-
player.volume = Math.min(1, player.volume + VOLUME_STEP);
|
|
1219
|
-
break;
|
|
1220
|
-
case "ArrowDown":
|
|
1221
|
-
e.preventDefault();
|
|
1222
|
-
player.volume = Math.max(0, player.volume - VOLUME_STEP);
|
|
1223
|
-
break;
|
|
1224
|
-
case "m":
|
|
1225
|
-
e.preventDefault();
|
|
1226
|
-
player.toggleMute();
|
|
1227
|
-
break;
|
|
1228
|
-
case "f":
|
|
1229
|
-
e.preventDefault();
|
|
1230
|
-
player.toggleFullscreen();
|
|
1231
|
-
break;
|
|
1232
|
-
case "0":
|
|
1233
|
-
case "Home":
|
|
1234
|
-
e.preventDefault();
|
|
1235
|
-
player.seek(0);
|
|
1236
|
-
break;
|
|
1237
|
-
case "End":
|
|
1238
|
-
e.preventDefault();
|
|
1239
|
-
player.seek(player.duration);
|
|
1240
|
-
break;
|
|
1241
|
-
case "1":
|
|
1242
|
-
case "2":
|
|
1243
|
-
case "3":
|
|
1244
|
-
case "4":
|
|
1245
|
-
case "5":
|
|
1246
|
-
case "6":
|
|
1247
|
-
case "7":
|
|
1248
|
-
case "8":
|
|
1249
|
-
case "9": {
|
|
1250
|
-
e.preventDefault();
|
|
1251
|
-
const pct = parseInt(e.key) / 10;
|
|
1252
|
-
player.seek(player.duration * pct);
|
|
1253
|
-
break;
|
|
1254
|
-
}
|
|
1255
|
-
case "<":
|
|
1256
|
-
e.preventDefault();
|
|
1257
|
-
this.cycleSpeed(-1);
|
|
1258
|
-
break;
|
|
1259
|
-
case ">":
|
|
1260
|
-
e.preventDefault();
|
|
1261
|
-
this.cycleSpeed(1);
|
|
1262
|
-
break;
|
|
1263
|
-
}
|
|
1264
|
-
};
|
|
1265
|
-
document.addEventListener("keydown", this.handler);
|
|
1266
|
-
}
|
|
1267
|
-
cycleSpeed(direction) {
|
|
1268
|
-
const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
1269
|
-
const current = this.player.playbackRate;
|
|
1270
|
-
const idx = speeds.indexOf(current);
|
|
1271
|
-
const next = idx + direction;
|
|
1272
|
-
if (next >= 0 && next < speeds.length) {
|
|
1273
|
-
this.player.playbackRate = speeds[next];
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
destroy() {
|
|
1277
|
-
document.removeEventListener("keydown", this.handler);
|
|
1278
|
-
}
|
|
1279
|
-
};
|
|
1280
|
-
|
|
1281
|
-
// src/input/TouchManager.ts
|
|
1282
|
-
var TouchManager = class {
|
|
1283
|
-
constructor(player, wrapper) {
|
|
1284
|
-
this.lastTapTime = 0;
|
|
1285
|
-
this.lastTapX = 0;
|
|
1286
|
-
this.tapTimeout = null;
|
|
1287
|
-
this.player = player;
|
|
1288
|
-
this.wrapper = wrapper;
|
|
1289
|
-
this.handler = (e) => {
|
|
1290
|
-
const target = e.target;
|
|
1291
|
-
if (target.closest(".gallop-controls") || target.closest(".gallop-big-play")) {
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
const touch = e.changedTouches[0];
|
|
1295
|
-
const now = Date.now();
|
|
1296
|
-
const timeDiff = now - this.lastTapTime;
|
|
1297
|
-
if (timeDiff < DOUBLE_TAP_DELAY) {
|
|
1298
|
-
if (this.tapTimeout) {
|
|
1299
|
-
clearTimeout(this.tapTimeout);
|
|
1300
|
-
this.tapTimeout = null;
|
|
1301
|
-
}
|
|
1302
|
-
const rect = wrapper.getBoundingClientRect();
|
|
1303
|
-
const x = touch.clientX - rect.left;
|
|
1304
|
-
const halfWidth = rect.width / 2;
|
|
1305
|
-
if (x < halfWidth) {
|
|
1306
|
-
player.seekBackward(SEEK_STEP_LARGE);
|
|
1307
|
-
} else {
|
|
1308
|
-
player.seekForward(SEEK_STEP_LARGE);
|
|
1309
|
-
}
|
|
1310
|
-
} else {
|
|
1311
|
-
this.tapTimeout = setTimeout(() => {
|
|
1312
|
-
player.togglePlay();
|
|
1313
|
-
this.tapTimeout = null;
|
|
1314
|
-
}, DOUBLE_TAP_DELAY);
|
|
1315
|
-
}
|
|
1316
|
-
this.lastTapTime = now;
|
|
1317
|
-
this.lastTapX = touch.clientX;
|
|
1318
|
-
};
|
|
1319
|
-
wrapper.addEventListener("touchend", this.handler);
|
|
1320
|
-
}
|
|
1321
|
-
destroy() {
|
|
1322
|
-
this.wrapper.removeEventListener("touchend", this.handler);
|
|
1323
|
-
if (this.tapTimeout) clearTimeout(this.tapTimeout);
|
|
1324
|
-
}
|
|
1325
|
-
};
|
|
1326
|
-
|
|
1327
|
-
// src/theme/styles.ts
|
|
1328
|
-
var PLAYER_STYLES = `
|
|
1329
|
-
/* ===== Gallop Player \u2014 ScaleMule Video Player ===== */
|
|
1330
|
-
|
|
1331
|
-
/* --- Foundation --- */
|
|
1332
|
-
.gallop-player {
|
|
1333
|
-
position: relative;
|
|
1334
|
-
width: 100%;
|
|
1335
|
-
max-width: 100%;
|
|
1336
|
-
background: #000;
|
|
1337
|
-
overflow: hidden;
|
|
1338
|
-
border-radius: var(--gallop-border-radius, 8px);
|
|
1339
|
-
font-family: var(--gallop-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
|
1340
|
-
font-size: var(--gallop-font-size, 13px);
|
|
1341
|
-
color: #fff;
|
|
1342
|
-
user-select: none;
|
|
1343
|
-
-webkit-user-select: none;
|
|
1344
|
-
outline: none;
|
|
1345
|
-
line-height: 1.4;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
.gallop-player * {
|
|
1349
|
-
box-sizing: border-box;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
.gallop-player.gallop-fullscreen {
|
|
1353
|
-
border-radius: 0;
|
|
1354
|
-
width: 100vw;
|
|
1355
|
-
height: 100vh;
|
|
1356
|
-
max-width: none;
|
|
1357
|
-
aspect-ratio: auto !important;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
.gallop-video {
|
|
1361
|
-
display: block;
|
|
1362
|
-
width: 100%;
|
|
1363
|
-
height: 100%;
|
|
1364
|
-
object-fit: contain;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
/* --- Icon base --- */
|
|
1368
|
-
.gallop-icon {
|
|
1369
|
-
display: inline-flex;
|
|
1370
|
-
align-items: center;
|
|
1371
|
-
justify-content: center;
|
|
1372
|
-
width: var(--gallop-icon-size, 22px);
|
|
1373
|
-
height: var(--gallop-icon-size, 22px);
|
|
1374
|
-
flex-shrink: 0;
|
|
1375
|
-
}
|
|
1376
|
-
.gallop-icon svg {
|
|
1377
|
-
width: 100%;
|
|
1378
|
-
height: 100%;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
/* ===================================================
|
|
1382
|
-
BIG PLAY BUTTON \u2014 Signature rounded-rect with gradient
|
|
1383
|
-
=================================================== */
|
|
1384
|
-
.gallop-big-play {
|
|
1385
|
-
position: absolute;
|
|
1386
|
-
top: 50%;
|
|
1387
|
-
left: 50%;
|
|
1388
|
-
transform: translate(-50%, -50%);
|
|
1389
|
-
width: 104px;
|
|
1390
|
-
height: 72px;
|
|
1391
|
-
background: var(--gallop-color-primary, #635bff);
|
|
1392
|
-
border: none;
|
|
1393
|
-
border-radius: 18px;
|
|
1394
|
-
cursor: pointer;
|
|
1395
|
-
display: flex;
|
|
1396
|
-
align-items: center;
|
|
1397
|
-
justify-content: center;
|
|
1398
|
-
color: #fff;
|
|
1399
|
-
opacity: 0.92;
|
|
1400
|
-
transition: opacity 0.25s, transform 0.25s, box-shadow 0.25s;
|
|
1401
|
-
z-index: 4;
|
|
1402
|
-
padding: 0;
|
|
1403
|
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
.gallop-big-play:hover {
|
|
1407
|
-
opacity: 1;
|
|
1408
|
-
transform: translate(-50%, -50%) scale(1.06);
|
|
1409
|
-
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.4);
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
.gallop-big-play .gallop-icon {
|
|
1413
|
-
width: 40px;
|
|
1414
|
-
height: 40px;
|
|
1415
|
-
margin-left: 4px;
|
|
1416
|
-
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
.gallop-big-play[hidden] {
|
|
1420
|
-
display: none;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
/* When controls are visible during pause, dim the big play button slightly */
|
|
1424
|
-
.gallop-player[data-status="paused"] .gallop-big-play {
|
|
1425
|
-
opacity: 0.85;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
/* ===================================================
|
|
1429
|
-
LOADING SPINNER \u2014 Brand-colored ring
|
|
1430
|
-
=================================================== */
|
|
1431
|
-
.gallop-spinner {
|
|
1432
|
-
position: absolute;
|
|
1433
|
-
top: 50%;
|
|
1434
|
-
left: 50%;
|
|
1435
|
-
transform: translate(-50%, -50%);
|
|
1436
|
-
width: 48px;
|
|
1437
|
-
height: 48px;
|
|
1438
|
-
z-index: 3;
|
|
1439
|
-
}
|
|
1440
|
-
.gallop-spinner[hidden] { display: none; }
|
|
1441
|
-
|
|
1442
|
-
.gallop-spinner-ring {
|
|
1443
|
-
width: 100%;
|
|
1444
|
-
height: 100%;
|
|
1445
|
-
border: 3px solid rgba(255, 255, 255, 0.15);
|
|
1446
|
-
border-top-color: var(--gallop-color-primary, #635bff);
|
|
1447
|
-
border-radius: 50%;
|
|
1448
|
-
animation: gallop-spin 0.8s linear infinite;
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
@keyframes gallop-spin {
|
|
1452
|
-
to { transform: rotate(360deg); }
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
/* ===================================================
|
|
1456
|
-
POSTER IMAGE
|
|
1457
|
-
=================================================== */
|
|
1458
|
-
.gallop-poster {
|
|
1459
|
-
position: absolute;
|
|
1460
|
-
inset: 0;
|
|
1461
|
-
z-index: 2;
|
|
1462
|
-
background-size: cover;
|
|
1463
|
-
background-position: center;
|
|
1464
|
-
background-repeat: no-repeat;
|
|
1465
|
-
transition: opacity 0.4s ease;
|
|
1466
|
-
}
|
|
1467
|
-
.gallop-poster[hidden] { display: none; }
|
|
1468
|
-
|
|
1469
|
-
/* ===================================================
|
|
1470
|
-
ERROR OVERLAY
|
|
1471
|
-
=================================================== */
|
|
1472
|
-
.gallop-error {
|
|
1473
|
-
position: absolute;
|
|
1474
|
-
inset: 0;
|
|
1475
|
-
z-index: 5;
|
|
1476
|
-
background: rgba(0, 0, 0, 0.88);
|
|
1477
|
-
display: flex;
|
|
1478
|
-
flex-direction: column;
|
|
1479
|
-
align-items: center;
|
|
1480
|
-
justify-content: center;
|
|
1481
|
-
gap: 16px;
|
|
1482
|
-
}
|
|
1483
|
-
.gallop-error[hidden] { display: none; }
|
|
1484
|
-
|
|
1485
|
-
.gallop-error-message {
|
|
1486
|
-
font-size: 15px;
|
|
1487
|
-
color: rgba(255, 255, 255, 0.75);
|
|
1488
|
-
text-align: center;
|
|
1489
|
-
max-width: 80%;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
.gallop-error-retry {
|
|
1493
|
-
padding: 10px 28px;
|
|
1494
|
-
background: var(--gallop-color-primary, #635bff);
|
|
1495
|
-
color: #fff;
|
|
1496
|
-
border: none;
|
|
1497
|
-
border-radius: 10px;
|
|
1498
|
-
font-size: 14px;
|
|
1499
|
-
font-weight: 500;
|
|
1500
|
-
cursor: pointer;
|
|
1501
|
-
transition: transform 0.2s, box-shadow 0.2s;
|
|
1502
|
-
}
|
|
1503
|
-
.gallop-error-retry:hover {
|
|
1504
|
-
transform: scale(1.04);
|
|
1505
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
/* ===================================================
|
|
1509
|
-
CONTROL BAR \u2014 Floating rounded pill
|
|
1510
|
-
=================================================== */
|
|
1511
|
-
.gallop-controls {
|
|
1512
|
-
position: absolute;
|
|
1513
|
-
bottom: 10px;
|
|
1514
|
-
left: 10px;
|
|
1515
|
-
right: 10px;
|
|
1516
|
-
z-index: 10;
|
|
1517
|
-
background: var(--gallop-control-bar-bg, rgba(0, 0, 0, 0.65));
|
|
1518
|
-
border-radius: 22px;
|
|
1519
|
-
padding: 0;
|
|
1520
|
-
opacity: 1;
|
|
1521
|
-
transition: opacity 0.35s ease;
|
|
1522
|
-
backdrop-filter: blur(12px);
|
|
1523
|
-
-webkit-backdrop-filter: blur(12px);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
/* Hide controls during active playback (not paused/idle/ready/ended) */
|
|
1527
|
-
.gallop-player:not(:hover):not(:focus-within):not([data-status="paused"]):not([data-status="idle"]):not([data-status="ready"]):not([data-status="ended"]) .gallop-controls.gallop-controls-hidden {
|
|
1528
|
-
opacity: 0;
|
|
1529
|
-
pointer-events: none;
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
/* Also hide big play button during active playback when controls hidden */
|
|
1533
|
-
.gallop-player:not(:hover):not(:focus-within)[data-status="playing"] .gallop-big-play {
|
|
1534
|
-
opacity: 0;
|
|
1535
|
-
pointer-events: none;
|
|
1536
|
-
transition: opacity 0.35s ease;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
.gallop-controls-row {
|
|
1540
|
-
display: flex;
|
|
1541
|
-
align-items: center;
|
|
1542
|
-
gap: 6px;
|
|
1543
|
-
height: var(--gallop-control-bar-height, 44px);
|
|
1544
|
-
padding: 0 6px 0 8px;
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
/* --- Control Buttons --- */
|
|
1548
|
-
.gallop-btn {
|
|
1549
|
-
background: none;
|
|
1550
|
-
border: none;
|
|
1551
|
-
color: rgba(255, 255, 255, 0.9);
|
|
1552
|
-
cursor: pointer;
|
|
1553
|
-
padding: 6px;
|
|
1554
|
-
border-radius: 6px;
|
|
1555
|
-
display: flex;
|
|
1556
|
-
align-items: center;
|
|
1557
|
-
justify-content: center;
|
|
1558
|
-
transition: background 0.15s, color 0.15s, transform 0.15s;
|
|
1559
|
-
position: relative;
|
|
1560
|
-
}
|
|
1561
|
-
.gallop-btn:hover {
|
|
1562
|
-
background: rgba(255, 255, 255, 0.12);
|
|
1563
|
-
color: #fff;
|
|
1564
|
-
transform: scale(1.05);
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
/* ===================================================
|
|
1568
|
-
PROGRESS BAR \u2014 Thick, bold, TikTok-inspired
|
|
1569
|
-
=================================================== */
|
|
1570
|
-
.gallop-progress-container {
|
|
1571
|
-
flex: 1;
|
|
1572
|
-
min-width: 0;
|
|
1573
|
-
padding: 0;
|
|
1574
|
-
cursor: pointer;
|
|
1575
|
-
display: flex;
|
|
1576
|
-
align-items: center;
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
.gallop-progress-bar {
|
|
1580
|
-
position: relative;
|
|
1581
|
-
width: 100%;
|
|
1582
|
-
height: 4px;
|
|
1583
|
-
background: rgba(255, 255, 255, 0.2);
|
|
1584
|
-
border-radius: 2px;
|
|
1585
|
-
transition: height 0.15s ease;
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
.gallop-progress-container:hover .gallop-progress-bar {
|
|
1589
|
-
height: 6px;
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
.gallop-progress-buffered {
|
|
1593
|
-
position: absolute;
|
|
1594
|
-
left: 0;
|
|
1595
|
-
top: 0;
|
|
1596
|
-
height: 100%;
|
|
1597
|
-
background: rgba(255, 255, 255, 0.22);
|
|
1598
|
-
border-radius: 2px;
|
|
1599
|
-
pointer-events: none;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
.gallop-progress-played {
|
|
1603
|
-
position: absolute;
|
|
1604
|
-
left: 0;
|
|
1605
|
-
top: 0;
|
|
1606
|
-
height: 100%;
|
|
1607
|
-
background: var(--gallop-color-progress, var(--gallop-color-primary, #635bff));
|
|
1608
|
-
border-radius: 2px;
|
|
1609
|
-
pointer-events: none;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
.gallop-progress-thumb {
|
|
1613
|
-
position: absolute;
|
|
1614
|
-
top: 50%;
|
|
1615
|
-
width: 12px;
|
|
1616
|
-
height: 12px;
|
|
1617
|
-
background: #fff;
|
|
1618
|
-
border: 2px solid var(--gallop-color-primary, #635bff);
|
|
1619
|
-
border-radius: 50%;
|
|
1620
|
-
transform: translate(-50%, -50%);
|
|
1621
|
-
opacity: 0;
|
|
1622
|
-
transition: opacity 0.15s, transform 0.15s;
|
|
1623
|
-
pointer-events: none;
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
.gallop-progress-container:hover .gallop-progress-thumb {
|
|
1627
|
-
opacity: 1;
|
|
1628
|
-
transform: translate(-50%, -50%) scale(1.1);
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
/* ===================================================
|
|
1632
|
-
VOLUME
|
|
1633
|
-
=================================================== */
|
|
1634
|
-
.gallop-volume {
|
|
1635
|
-
display: flex;
|
|
1636
|
-
align-items: center;
|
|
1637
|
-
gap: 4px;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
.gallop-volume-slider-wrap {
|
|
1641
|
-
width: 0;
|
|
1642
|
-
overflow: hidden;
|
|
1643
|
-
transition: width 0.2s ease;
|
|
1644
|
-
}
|
|
1645
|
-
.gallop-volume:hover .gallop-volume-slider-wrap,
|
|
1646
|
-
.gallop-volume-slider-wrap.gallop-volume-expanded {
|
|
1647
|
-
width: 64px;
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1650
|
-
.gallop-volume-slider {
|
|
1651
|
-
width: 64px;
|
|
1652
|
-
height: 4px;
|
|
1653
|
-
background: rgba(255, 255, 255, 0.18);
|
|
1654
|
-
border-radius: 2px;
|
|
1655
|
-
position: relative;
|
|
1656
|
-
cursor: pointer;
|
|
1657
|
-
}
|
|
1658
|
-
.gallop-volume-fill {
|
|
1659
|
-
position: absolute;
|
|
1660
|
-
left: 0;
|
|
1661
|
-
top: 0;
|
|
1662
|
-
height: 100%;
|
|
1663
|
-
background: rgba(255, 255, 255, 0.85);
|
|
1664
|
-
border-radius: 2px;
|
|
1665
|
-
pointer-events: none;
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
/* ===================================================
|
|
1669
|
-
TIME DISPLAY
|
|
1670
|
-
=================================================== */
|
|
1671
|
-
.gallop-time {
|
|
1672
|
-
font-size: 12px;
|
|
1673
|
-
color: rgba(255, 255, 255, 0.85);
|
|
1674
|
-
white-space: nowrap;
|
|
1675
|
-
font-variant-numeric: tabular-nums;
|
|
1676
|
-
min-width: 40px;
|
|
1677
|
-
letter-spacing: 0.02em;
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
/* Spacer */
|
|
1681
|
-
.gallop-spacer { flex: 1; }
|
|
1682
|
-
|
|
1683
|
-
/* ===================================================
|
|
1684
|
-
SETTINGS MENU
|
|
1685
|
-
=================================================== */
|
|
1686
|
-
.gallop-settings-menu {
|
|
1687
|
-
position: absolute;
|
|
1688
|
-
bottom: 100%;
|
|
1689
|
-
right: 0;
|
|
1690
|
-
margin-bottom: 8px;
|
|
1691
|
-
min-width: 200px;
|
|
1692
|
-
background: var(--gallop-color-background, rgba(24, 24, 32, 0.92));
|
|
1693
|
-
border-radius: 12px;
|
|
1694
|
-
padding: 6px 0;
|
|
1695
|
-
backdrop-filter: blur(16px);
|
|
1696
|
-
-webkit-backdrop-filter: blur(16px);
|
|
1697
|
-
z-index: 20;
|
|
1698
|
-
max-height: 300px;
|
|
1699
|
-
overflow-y: auto;
|
|
1700
|
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1701
|
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
1702
|
-
}
|
|
1703
|
-
.gallop-settings-menu[hidden] { display: none; }
|
|
1704
|
-
|
|
1705
|
-
.gallop-settings-header {
|
|
1706
|
-
display: flex;
|
|
1707
|
-
align-items: center;
|
|
1708
|
-
gap: 8px;
|
|
1709
|
-
padding: 8px 14px;
|
|
1710
|
-
font-size: 13px;
|
|
1711
|
-
font-weight: 600;
|
|
1712
|
-
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
1713
|
-
cursor: pointer;
|
|
1714
|
-
color: rgba(255, 255, 255, 0.9);
|
|
1715
|
-
}
|
|
1716
|
-
.gallop-settings-header .gallop-icon {
|
|
1717
|
-
width: 18px;
|
|
1718
|
-
height: 18px;
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
.gallop-settings-item {
|
|
1722
|
-
display: flex;
|
|
1723
|
-
align-items: center;
|
|
1724
|
-
justify-content: space-between;
|
|
1725
|
-
padding: 8px 16px;
|
|
1726
|
-
cursor: pointer;
|
|
1727
|
-
transition: background 0.12s;
|
|
1728
|
-
font-size: 13px;
|
|
1729
|
-
gap: 12px;
|
|
1730
|
-
color: rgba(255, 255, 255, 0.8);
|
|
1731
|
-
}
|
|
1732
|
-
.gallop-settings-item:hover {
|
|
1733
|
-
background: rgba(255, 255, 255, 0.08);
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
.gallop-settings-item-active .gallop-icon {
|
|
1737
|
-
color: var(--gallop-color-primary, #635bff);
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
.gallop-settings-value {
|
|
1741
|
-
color: rgba(255, 255, 255, 0.5);
|
|
1742
|
-
font-size: 12px;
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
/* ===================================================
|
|
1746
|
-
CONTEXT MENU
|
|
1747
|
-
=================================================== */
|
|
1748
|
-
.gallop-context-menu {
|
|
1749
|
-
position: absolute;
|
|
1750
|
-
z-index: 30;
|
|
1751
|
-
min-width: 180px;
|
|
1752
|
-
background: var(--gallop-color-background, rgba(24, 24, 32, 0.94));
|
|
1753
|
-
border-radius: 10px;
|
|
1754
|
-
padding: 4px 0;
|
|
1755
|
-
backdrop-filter: blur(16px);
|
|
1756
|
-
-webkit-backdrop-filter: blur(16px);
|
|
1757
|
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1758
|
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
|
1759
|
-
}
|
|
1760
|
-
.gallop-context-menu[hidden] { display: none; }
|
|
1761
|
-
|
|
1762
|
-
.gallop-context-menu-item {
|
|
1763
|
-
padding: 9px 14px;
|
|
1764
|
-
font-size: 13px;
|
|
1765
|
-
color: rgba(255, 255, 255, 0.85);
|
|
1766
|
-
cursor: pointer;
|
|
1767
|
-
transition: background 0.12s;
|
|
1768
|
-
white-space: nowrap;
|
|
1769
|
-
}
|
|
1770
|
-
.gallop-context-menu-item:hover {
|
|
1771
|
-
background: rgba(255, 255, 255, 0.08);
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
/* ===================================================
|
|
1775
|
-
GALLOP BRANDING WATERMARK
|
|
1776
|
-
=================================================== */
|
|
1777
|
-
.gallop-brand {
|
|
1778
|
-
position: absolute;
|
|
1779
|
-
top: 12px;
|
|
1780
|
-
right: 12px;
|
|
1781
|
-
z-index: 6;
|
|
1782
|
-
display: flex;
|
|
1783
|
-
align-items: center;
|
|
1784
|
-
gap: 5px;
|
|
1785
|
-
padding: 4px 10px;
|
|
1786
|
-
background: rgba(0, 0, 0, 0.35);
|
|
1787
|
-
border-radius: 6px;
|
|
1788
|
-
backdrop-filter: blur(8px);
|
|
1789
|
-
-webkit-backdrop-filter: blur(8px);
|
|
1790
|
-
opacity: 0;
|
|
1791
|
-
transition: opacity 0.35s ease;
|
|
1792
|
-
pointer-events: none;
|
|
1793
|
-
font-size: 11px;
|
|
1794
|
-
font-weight: 600;
|
|
1795
|
-
letter-spacing: 0.04em;
|
|
1796
|
-
color: rgba(255, 255, 255, 0.7);
|
|
1797
|
-
text-transform: uppercase;
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
.gallop-brand-dot {
|
|
1801
|
-
width: 6px;
|
|
1802
|
-
height: 6px;
|
|
1803
|
-
border-radius: 50%;
|
|
1804
|
-
background: var(--gallop-color-primary, #635bff);
|
|
1805
|
-
flex-shrink: 0;
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
/* Show branding on hover */
|
|
1809
|
-
.gallop-player:hover .gallop-brand,
|
|
1810
|
-
.gallop-player:focus-within .gallop-brand,
|
|
1811
|
-
.gallop-player[data-status="paused"] .gallop-brand,
|
|
1812
|
-
.gallop-player[data-status="idle"] .gallop-brand,
|
|
1813
|
-
.gallop-player[data-status="ready"] .gallop-brand,
|
|
1814
|
-
.gallop-player[data-status="ended"] .gallop-brand {
|
|
1815
|
-
opacity: 1;
|
|
1816
|
-
pointer-events: auto;
|
|
1817
|
-
}
|
|
1818
|
-
`;
|
|
1819
|
-
|
|
1820
|
-
// src/analytics/AnalyticsCollector.ts
|
|
1821
|
-
var DEFAULTS = {
|
|
1822
|
-
flushIntervalMs: 3e3,
|
|
1823
|
-
maxBatchSize: 20,
|
|
1824
|
-
maxQueueSize: 300,
|
|
1825
|
-
progressIntervalSeconds: 10
|
|
1826
|
-
};
|
|
1827
|
-
var AnalyticsCollector = class {
|
|
1828
|
-
constructor(options) {
|
|
1829
|
-
this.queue = [];
|
|
1830
|
-
this.flushTimer = null;
|
|
1831
|
-
this.flushing = false;
|
|
1832
|
-
this.destroyed = false;
|
|
1833
|
-
this.playRequestedAtMs = null;
|
|
1834
|
-
this.ttffMs = null;
|
|
1835
|
-
this.firstFrameSeen = false;
|
|
1836
|
-
this.lastProgressPosition = 0;
|
|
1837
|
-
this.maxPositionSeen = 0;
|
|
1838
|
-
this.watchedSeconds = 0;
|
|
1839
|
-
this.lastHeartbeatPosition = 0;
|
|
1840
|
-
this.bufferStartedAtMs = null;
|
|
1841
|
-
this.totalBufferMs = 0;
|
|
1842
|
-
this.startupQuality = null;
|
|
1843
|
-
this.qualitySwitches = 0;
|
|
1844
|
-
this.lastQuality = null;
|
|
1845
|
-
this.lastEngineStats = null;
|
|
1846
|
-
this.cdnNode = null;
|
|
1847
|
-
this.cdnCacheStatus = null;
|
|
1848
|
-
this.cdnRequestID = null;
|
|
1849
|
-
this.initialDroppedFrames = null;
|
|
1850
|
-
this.droppedFrames = 0;
|
|
1851
|
-
this.isVisible = true;
|
|
1852
|
-
this.observer = null;
|
|
1853
|
-
this.onPlay = () => this.handlePlay();
|
|
1854
|
-
this.onPause = () => this.handlePause();
|
|
1855
|
-
this.onEnded = () => this.handleEnded();
|
|
1856
|
-
this.onSeeked = ({ time }) => this.handleSeeked(time);
|
|
1857
|
-
this.onTimeUpdate = ({ currentTime }) => this.handleTimeUpdate(currentTime);
|
|
1858
|
-
this.onBuffering = ({ isBuffering }) => this.handleBuffering(isBuffering);
|
|
1859
|
-
this.onQualityChange = ({ level }) => this.handleQualityChange(level);
|
|
1860
|
-
this.onError = ({ code, message }) => this.handleError(code, message);
|
|
1861
|
-
this.onVisibilityChange = () => {
|
|
1862
|
-
if (document.visibilityState === "hidden") {
|
|
1863
|
-
this.enqueue("pause", this.player.currentTime, { event_subtype: "document_hidden" }, true);
|
|
1864
|
-
void this.flush(true);
|
|
1865
|
-
}
|
|
1866
|
-
};
|
|
1867
|
-
this.onPageHide = () => {
|
|
1868
|
-
this.enqueue("pause", this.player.currentTime, { event_subtype: "pagehide" }, true);
|
|
1869
|
-
void this.flush(true);
|
|
1870
|
-
};
|
|
1871
|
-
this.client = options.client;
|
|
1872
|
-
this.player = options.player;
|
|
1873
|
-
this.videoId = options.videoId;
|
|
1874
|
-
this.config = options.config ?? {};
|
|
1875
|
-
this.sessionId = this.config.sessionId ?? createSessionId();
|
|
1876
|
-
this.bind();
|
|
1877
|
-
this.start();
|
|
1878
|
-
this.initVisibilityTracking();
|
|
1879
|
-
}
|
|
1880
|
-
setVideoId(videoId) {
|
|
1881
|
-
this.videoId = videoId;
|
|
1882
|
-
}
|
|
1883
|
-
onEngineStats(stats) {
|
|
1884
|
-
this.lastEngineStats = stats;
|
|
1885
|
-
if (stats.cdnNode) this.cdnNode = stats.cdnNode;
|
|
1886
|
-
if (stats.cdnCacheStatus) this.cdnCacheStatus = stats.cdnCacheStatus;
|
|
1887
|
-
if (stats.cdnRequestID) this.cdnRequestID = stats.cdnRequestID;
|
|
1888
|
-
if (stats.kind === "periodic" && stats.droppedFrames !== void 0) {
|
|
1889
|
-
if (this.initialDroppedFrames === null) {
|
|
1890
|
-
this.initialDroppedFrames = stats.droppedFrames;
|
|
1891
|
-
} else {
|
|
1892
|
-
this.droppedFrames = stats.droppedFrames - this.initialDroppedFrames;
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
trackEvent(eventType, timestampSeconds, metadata = {}, urgent = false) {
|
|
1897
|
-
this.enqueue(eventType, timestampSeconds, metadata, urgent);
|
|
1898
|
-
}
|
|
1899
|
-
async destroy() {
|
|
1900
|
-
if (this.destroyed) return;
|
|
1901
|
-
if (this.observer) {
|
|
1902
|
-
this.observer.disconnect();
|
|
1903
|
-
this.observer = null;
|
|
1904
|
-
}
|
|
1905
|
-
this.unbind();
|
|
1906
|
-
if (this.bufferStartedAtMs !== null) {
|
|
1907
|
-
const durationMs = Date.now() - this.bufferStartedAtMs;
|
|
1908
|
-
this.totalBufferMs += Math.max(0, durationMs);
|
|
1909
|
-
this.bufferStartedAtMs = null;
|
|
1910
|
-
}
|
|
1911
|
-
this.enqueue("pause", this.player.currentTime, { event_subtype: "destroy" }, true);
|
|
1912
|
-
await this.flush(true);
|
|
1913
|
-
if (this.flushTimer) {
|
|
1914
|
-
clearInterval(this.flushTimer);
|
|
1915
|
-
this.flushTimer = null;
|
|
1916
|
-
}
|
|
1917
|
-
this.destroyed = true;
|
|
1918
|
-
}
|
|
1919
|
-
bind() {
|
|
1920
|
-
this.player.on("play", this.onPlay);
|
|
1921
|
-
this.player.on("pause", this.onPause);
|
|
1922
|
-
this.player.on("ended", this.onEnded);
|
|
1923
|
-
this.player.on("seeked", this.onSeeked);
|
|
1924
|
-
this.player.on("timeupdate", this.onTimeUpdate);
|
|
1925
|
-
this.player.on("buffering", this.onBuffering);
|
|
1926
|
-
this.player.on("qualitychange", this.onQualityChange);
|
|
1927
|
-
this.player.on("error", this.onError);
|
|
1928
|
-
if (typeof document !== "undefined") {
|
|
1929
|
-
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
1930
|
-
}
|
|
1931
|
-
if (typeof window !== "undefined") {
|
|
1932
|
-
window.addEventListener("pagehide", this.onPageHide);
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
unbind() {
|
|
1936
|
-
this.player.off("play", this.onPlay);
|
|
1937
|
-
this.player.off("pause", this.onPause);
|
|
1938
|
-
this.player.off("ended", this.onEnded);
|
|
1939
|
-
this.player.off("seeked", this.onSeeked);
|
|
1940
|
-
this.player.off("timeupdate", this.onTimeUpdate);
|
|
1941
|
-
this.player.off("buffering", this.onBuffering);
|
|
1942
|
-
this.player.off("qualitychange", this.onQualityChange);
|
|
1943
|
-
this.player.off("error", this.onError);
|
|
1944
|
-
if (typeof document !== "undefined") {
|
|
1945
|
-
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
1946
|
-
}
|
|
1947
|
-
if (typeof window !== "undefined") {
|
|
1948
|
-
window.removeEventListener("pagehide", this.onPageHide);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
start() {
|
|
1952
|
-
this.flushTimer = setInterval(() => {
|
|
1953
|
-
void this.flush(false);
|
|
1954
|
-
}, this.config.flushIntervalMs ?? DEFAULTS.flushIntervalMs);
|
|
1955
|
-
}
|
|
1956
|
-
handlePlay() {
|
|
1957
|
-
this.playRequestedAtMs = Date.now();
|
|
1958
|
-
this.enqueue("play", this.player.currentTime);
|
|
1959
|
-
}
|
|
1960
|
-
handlePause() {
|
|
1961
|
-
this.enqueue("pause", this.player.currentTime);
|
|
1962
|
-
void this.flush(false);
|
|
1963
|
-
}
|
|
1964
|
-
handleEnded() {
|
|
1965
|
-
this.enqueue("complete", this.player.currentTime, {
|
|
1966
|
-
completion_ratio: safeRatio(this.player.currentTime, this.player.duration)
|
|
1967
|
-
}, true);
|
|
1968
|
-
void this.flush(false);
|
|
1969
|
-
}
|
|
1970
|
-
handleSeeked(time) {
|
|
1971
|
-
this.lastProgressPosition = time;
|
|
1972
|
-
this.maxPositionSeen = Math.max(this.maxPositionSeen, time);
|
|
1973
|
-
this.enqueue("seek", time);
|
|
1974
|
-
}
|
|
1975
|
-
handleTimeUpdate(currentTime) {
|
|
1976
|
-
if (!Number.isFinite(currentTime)) {
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
if (!this.firstFrameSeen && this.playRequestedAtMs !== null) {
|
|
1980
|
-
this.firstFrameSeen = true;
|
|
1981
|
-
this.ttffMs = Math.max(0, Date.now() - this.playRequestedAtMs);
|
|
1982
|
-
}
|
|
1983
|
-
if (this.lastProgressPosition > 0) {
|
|
1984
|
-
const delta = currentTime - this.lastProgressPosition;
|
|
1985
|
-
if (delta > 0 && delta < 5) {
|
|
1986
|
-
this.watchedSeconds += delta;
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
this.lastProgressPosition = currentTime;
|
|
1990
|
-
this.maxPositionSeen = Math.max(this.maxPositionSeen, currentTime);
|
|
1991
|
-
const progressInterval = this.config.progressIntervalSeconds ?? DEFAULTS.progressIntervalSeconds;
|
|
1992
|
-
if (Math.abs(currentTime - this.lastHeartbeatPosition) >= progressInterval) {
|
|
1993
|
-
this.lastHeartbeatPosition = currentTime;
|
|
1994
|
-
this.enqueue("play", currentTime, { event_subtype: "heartbeat" });
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
initVisibilityTracking() {
|
|
1998
|
-
if (this.config.trackVisibility === false || typeof IntersectionObserver === "undefined") {
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
2002
|
-
const entry = entries[0];
|
|
2003
|
-
if (entry) {
|
|
2004
|
-
const wasVisible = this.isVisible;
|
|
2005
|
-
this.isVisible = entry.isIntersecting;
|
|
2006
|
-
if (wasVisible !== this.isVisible) {
|
|
2007
|
-
this.enqueue("play", this.player.currentTime, {
|
|
2008
|
-
event_subtype: "visibility_change",
|
|
2009
|
-
is_visible: this.isVisible,
|
|
2010
|
-
intersection_ratio: round(entry.intersectionRatio, 3)
|
|
2011
|
-
});
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
}, { threshold: [0, 0.25, 0.5, 0.75, 1] });
|
|
2015
|
-
const el2 = this.player.getWrapperElement();
|
|
2016
|
-
if (el2) {
|
|
2017
|
-
this.observer.observe(el2);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
handleBuffering(isBuffering) {
|
|
2021
|
-
if (isBuffering) {
|
|
2022
|
-
if (this.bufferStartedAtMs !== null) return;
|
|
2023
|
-
this.bufferStartedAtMs = Date.now();
|
|
2024
|
-
this.enqueue("buffer", this.player.currentTime, { buffering_state: "start" });
|
|
2025
|
-
return;
|
|
2026
|
-
}
|
|
2027
|
-
if (this.bufferStartedAtMs === null) return;
|
|
2028
|
-
const durationMs = Math.max(0, Date.now() - this.bufferStartedAtMs);
|
|
2029
|
-
this.totalBufferMs += durationMs;
|
|
2030
|
-
this.bufferStartedAtMs = null;
|
|
2031
|
-
this.enqueue("buffer", this.player.currentTime, {
|
|
2032
|
-
buffering_state: "end",
|
|
2033
|
-
buffering_duration_ms: Math.round(durationMs)
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
handleQualityChange(level) {
|
|
2037
|
-
const label = level.label || `${level.height}p`;
|
|
2038
|
-
if (!this.startupQuality) {
|
|
2039
|
-
this.startupQuality = label;
|
|
2040
|
-
}
|
|
2041
|
-
if (this.lastQuality && this.lastQuality !== label) {
|
|
2042
|
-
this.qualitySwitches += 1;
|
|
2043
|
-
}
|
|
2044
|
-
this.lastQuality = label;
|
|
2045
|
-
this.enqueue("play", this.player.currentTime, {
|
|
2046
|
-
event_subtype: "quality_change",
|
|
2047
|
-
quality_label: label,
|
|
2048
|
-
quality_bitrate: level.bitrate,
|
|
2049
|
-
quality_height: level.height,
|
|
2050
|
-
quality_width: level.width
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
handleError(code, message) {
|
|
2054
|
-
this.enqueue("error", this.player.currentTime, {
|
|
2055
|
-
error_code: code,
|
|
2056
|
-
error_message: message,
|
|
2057
|
-
error_classification: classifyError(code, this.lastEngineStats)
|
|
2058
|
-
}, true);
|
|
2059
|
-
void this.flush(false);
|
|
2060
|
-
}
|
|
2061
|
-
enqueue(eventType, timestampSeconds, metadata = {}, urgent = false) {
|
|
2062
|
-
if (!this.videoId) {
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
const quality = this.resolveQuality();
|
|
2066
|
-
const payload = {
|
|
2067
|
-
session_id: this.sessionId,
|
|
2068
|
-
event_type: eventType,
|
|
2069
|
-
timestamp_seconds: Number.isFinite(timestampSeconds) ? round(timestampSeconds, 3) : void 0,
|
|
2070
|
-
quality: quality ?? void 0,
|
|
2071
|
-
metadata: this.buildMetadata(metadata)
|
|
2072
|
-
};
|
|
2073
|
-
this.queue.push(payload);
|
|
2074
|
-
const maxQueue = this.config.maxQueueSize ?? DEFAULTS.maxQueueSize;
|
|
2075
|
-
if (this.queue.length > maxQueue) {
|
|
2076
|
-
this.queue.splice(0, this.queue.length - maxQueue);
|
|
2077
|
-
}
|
|
2078
|
-
if (this.config.debug) {
|
|
2079
|
-
console.debug("[Gallop analytics] queued event", payload);
|
|
2080
|
-
}
|
|
2081
|
-
if (urgent) {
|
|
2082
|
-
void this.flush(false);
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
async flush(keepalive) {
|
|
2086
|
-
if (this.flushing || this.queue.length === 0) {
|
|
2087
|
-
return;
|
|
2088
|
-
}
|
|
2089
|
-
this.flushing = true;
|
|
2090
|
-
const maxBatchSize = this.config.maxBatchSize ?? DEFAULTS.maxBatchSize;
|
|
2091
|
-
try {
|
|
2092
|
-
while (this.queue.length > 0) {
|
|
2093
|
-
const batch = this.queue.splice(0, maxBatchSize);
|
|
2094
|
-
for (let i = 0; i < batch.length; i++) {
|
|
2095
|
-
try {
|
|
2096
|
-
await this.client.trackPlayback(this.videoId, batch[i], { keepalive });
|
|
2097
|
-
} catch (err) {
|
|
2098
|
-
if (this.config.debug) {
|
|
2099
|
-
console.warn("[Gallop analytics] failed to send event", { event: batch[i], err });
|
|
2100
|
-
}
|
|
2101
|
-
this.queue.unshift(...batch.slice(i));
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
} finally {
|
|
2107
|
-
this.flushing = false;
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
buildMetadata(overrides) {
|
|
2111
|
-
const watchSeconds = round(this.watchedSeconds, 3);
|
|
2112
|
-
const currentTime = round(this.player.currentTime, 3);
|
|
2113
|
-
const duration = round(this.player.duration, 3);
|
|
2114
|
-
const rebufferRatio = safeRatio(this.totalBufferMs / 1e3, this.watchedSeconds);
|
|
2115
|
-
const buffered = this.player.buffered;
|
|
2116
|
-
let bufferDepth = 0;
|
|
2117
|
-
for (let i = 0; i < buffered.length; i++) {
|
|
2118
|
-
if (currentTime >= buffered.start(i) && currentTime <= buffered.end(i)) {
|
|
2119
|
-
bufferDepth = buffered.end(i) - currentTime;
|
|
2120
|
-
break;
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
const metadata = {
|
|
2124
|
-
...this.config.metadata ?? {},
|
|
2125
|
-
...overrides,
|
|
2126
|
-
current_time_seconds: currentTime,
|
|
2127
|
-
duration_seconds: duration,
|
|
2128
|
-
watched_seconds: watchSeconds,
|
|
2129
|
-
max_position_seconds: round(this.maxPositionSeen, 3),
|
|
2130
|
-
watch_through_ratio: safeRatio(this.maxPositionSeen, this.player.duration),
|
|
2131
|
-
completion_ratio: safeRatio(this.player.currentTime, this.player.duration),
|
|
2132
|
-
ttff_ms: this.ttffMs,
|
|
2133
|
-
total_buffer_ms: Math.round(this.totalBufferMs),
|
|
2134
|
-
rebuffer_ratio: rebufferRatio,
|
|
2135
|
-
buffer_depth_seconds: round(bufferDepth, 3),
|
|
2136
|
-
quality_switches: this.qualitySwitches,
|
|
2137
|
-
startup_quality: this.startupQuality,
|
|
2138
|
-
dropped_frames: this.droppedFrames,
|
|
2139
|
-
is_visible: this.isVisible,
|
|
2140
|
-
cdn_node: this.cdnNode,
|
|
2141
|
-
cdn_cache_status: this.cdnCacheStatus,
|
|
2142
|
-
cdn_request_id: this.cdnRequestID,
|
|
2143
|
-
playback_rate: this.player.playbackRate,
|
|
2144
|
-
muted: this.player.muted,
|
|
2145
|
-
volume: round(this.player.volume, 3),
|
|
2146
|
-
status: this.player.status,
|
|
2147
|
-
viewport_width: typeof window !== "undefined" ? window.innerWidth : void 0,
|
|
2148
|
-
viewport_height: typeof window !== "undefined" ? window.innerHeight : void 0,
|
|
2149
|
-
device_pixel_ratio: typeof window !== "undefined" ? window.devicePixelRatio : void 0,
|
|
2150
|
-
hls_bandwidth_estimate_bps: this.lastEngineStats?.bandwidthEstimate,
|
|
2151
|
-
hls_stats_kind: this.lastEngineStats?.kind,
|
|
2152
|
-
hls_level: this.lastEngineStats?.level,
|
|
2153
|
-
hls_fragment_duration: this.lastEngineStats?.fragmentDuration,
|
|
2154
|
-
hls_fragment_size_bytes: this.lastEngineStats?.fragmentSizeBytes,
|
|
2155
|
-
hls_fragment_load_ms: this.lastEngineStats?.fragmentLoadMs,
|
|
2156
|
-
hls_error_type: this.lastEngineStats?.errorType,
|
|
2157
|
-
hls_error_details: this.lastEngineStats?.errorDetails,
|
|
2158
|
-
hls_error_fatal: this.lastEngineStats?.fatal,
|
|
2159
|
-
stats_source: this.lastEngineStats?.statsSource,
|
|
2160
|
-
tao_available: this.lastEngineStats?.taoAvailable
|
|
2161
|
-
};
|
|
2162
|
-
if (this.config.includeNetworkInfo ?? true) {
|
|
2163
|
-
const networkInfo = getNetworkInfo();
|
|
2164
|
-
metadata.connection_effective_type = networkInfo.effectiveType;
|
|
2165
|
-
metadata.connection_downlink_mbps = networkInfo.downlink;
|
|
2166
|
-
metadata.connection_rtt_ms = networkInfo.rtt;
|
|
2167
|
-
metadata.connection_save_data = networkInfo.saveData;
|
|
2168
|
-
}
|
|
2169
|
-
if (this.config.includeDeviceInfo ?? true) {
|
|
2170
|
-
metadata.user_agent = typeof navigator !== "undefined" ? navigator.userAgent : void 0;
|
|
2171
|
-
metadata.language = typeof navigator !== "undefined" ? navigator.language : void 0;
|
|
2172
|
-
metadata.platform = typeof navigator !== "undefined" ? navigator.platform : void 0;
|
|
2173
|
-
metadata.device_memory_gb = typeof navigator !== "undefined" ? navigator.deviceMemory : void 0;
|
|
2174
|
-
metadata.hardware_concurrency = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : void 0;
|
|
2175
|
-
metadata.screen_width = typeof screen !== "undefined" ? screen.width : void 0;
|
|
2176
|
-
metadata.screen_height = typeof screen !== "undefined" ? screen.height : void 0;
|
|
2177
|
-
}
|
|
2178
|
-
return removeUndefined(metadata);
|
|
2179
|
-
}
|
|
2180
|
-
resolveQuality() {
|
|
2181
|
-
const active = this.player.getQualityLevels().find((level) => level.active);
|
|
2182
|
-
if (active) {
|
|
2183
|
-
return active.label || `${active.height}p`;
|
|
2184
|
-
}
|
|
2185
|
-
if (this.lastQuality) {
|
|
2186
|
-
return this.lastQuality;
|
|
2187
|
-
}
|
|
2188
|
-
const currentIndex = this.player.getCurrentQuality();
|
|
2189
|
-
if (currentIndex >= 0) {
|
|
2190
|
-
return `level_${currentIndex}`;
|
|
2191
|
-
}
|
|
2192
|
-
return null;
|
|
2193
|
-
}
|
|
2194
|
-
};
|
|
2195
|
-
function getNetworkInfo() {
|
|
2196
|
-
if (typeof navigator === "undefined") {
|
|
2197
|
-
return {};
|
|
2198
|
-
}
|
|
2199
|
-
const connection = navigator.connection;
|
|
2200
|
-
if (!connection) {
|
|
2201
|
-
return {};
|
|
2202
|
-
}
|
|
2203
|
-
return {
|
|
2204
|
-
effectiveType: connection.effectiveType,
|
|
2205
|
-
downlink: connection.downlink,
|
|
2206
|
-
rtt: connection.rtt,
|
|
2207
|
-
saveData: connection.saveData
|
|
2208
|
-
};
|
|
2209
|
-
}
|
|
2210
|
-
function classifyError(code, stats) {
|
|
2211
|
-
const lowered = code.toLowerCase();
|
|
2212
|
-
if (lowered.includes("network") || stats?.errorType?.toLowerCase().includes("network")) {
|
|
2213
|
-
return "network";
|
|
2214
|
-
}
|
|
2215
|
-
if (lowered.includes("media") || stats?.errorType?.toLowerCase().includes("media")) {
|
|
2216
|
-
return "media";
|
|
2217
|
-
}
|
|
2218
|
-
return "unknown";
|
|
2219
|
-
}
|
|
2220
|
-
function createSessionId() {
|
|
2221
|
-
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
2222
|
-
return crypto.randomUUID();
|
|
2223
|
-
}
|
|
2224
|
-
return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
2225
|
-
}
|
|
2226
|
-
function round(value, digits) {
|
|
2227
|
-
if (!Number.isFinite(value)) {
|
|
2228
|
-
return 0;
|
|
2229
|
-
}
|
|
2230
|
-
const factor = 10 ** digits;
|
|
2231
|
-
return Math.round(value * factor) / factor;
|
|
2232
|
-
}
|
|
2233
|
-
function safeRatio(numerator, denominator) {
|
|
2234
|
-
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
|
2235
|
-
return 0;
|
|
2236
|
-
}
|
|
2237
|
-
return round(numerator / denominator, 4);
|
|
2238
|
-
}
|
|
2239
|
-
function removeUndefined(metadata) {
|
|
2240
|
-
return Object.fromEntries(
|
|
2241
|
-
Object.entries(metadata).filter(([, value]) => value !== void 0)
|
|
2242
|
-
);
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
// src/utils/csp.ts
|
|
2246
|
-
function resolveNonce(explicitNonce) {
|
|
2247
|
-
if (explicitNonce) return explicitNonce;
|
|
2248
|
-
if (typeof document !== "undefined" && document.currentScript instanceof HTMLScriptElement && document.currentScript.nonce) {
|
|
2249
|
-
return document.currentScript.nonce;
|
|
2250
|
-
}
|
|
2251
|
-
if (typeof document !== "undefined") {
|
|
2252
|
-
const el2 = document.querySelector("script[nonce], style[nonce]");
|
|
2253
|
-
if (el2) {
|
|
2254
|
-
return el2.nonce || el2.getAttribute("nonce") || void 0;
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
return void 0;
|
|
2258
|
-
}
|
|
2259
|
-
function detectCSPFailure(styleEl, onFailure) {
|
|
2260
|
-
if (typeof document === "undefined") return;
|
|
2261
|
-
const violationHandler = (e) => {
|
|
2262
|
-
if (e.blockedURI === "inline" || e.violatedDirective.startsWith("style-src")) {
|
|
2263
|
-
onFailure(`CSP blocked style injection: ${e.violatedDirective}`);
|
|
2264
|
-
cleanup();
|
|
2265
|
-
}
|
|
2266
|
-
};
|
|
2267
|
-
document.addEventListener("securitypolicyviolation", violationHandler);
|
|
2268
|
-
requestAnimationFrame(() => {
|
|
2269
|
-
const testEl = styleEl.parentElement?.querySelector(".gallop-player");
|
|
2270
|
-
if (testEl) {
|
|
2271
|
-
const computed = getComputedStyle(testEl);
|
|
2272
|
-
if (computed.position === "static") {
|
|
2273
|
-
onFailure("CSP may have blocked style injection (computed style mismatch)");
|
|
2274
|
-
cleanup();
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
});
|
|
2278
|
-
setTimeout(() => {
|
|
2279
|
-
if (!styleEl.sheet) {
|
|
2280
|
-
onFailure("Style sheet not applied (sheet is null)");
|
|
2281
|
-
cleanup();
|
|
2282
|
-
}
|
|
2283
|
-
}, 100);
|
|
2284
|
-
const cleanup = () => {
|
|
2285
|
-
document.removeEventListener("securitypolicyviolation", violationHandler);
|
|
2286
|
-
};
|
|
2287
|
-
setTimeout(cleanup, 2e3);
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
// src/debug/DebugOverlay.ts
|
|
2291
|
-
var DebugOverlay = class {
|
|
2292
|
-
constructor(parent, config, getDiagnostics) {
|
|
2293
|
-
this.parent = parent;
|
|
2294
|
-
this.config = config;
|
|
2295
|
-
this.getDiagnostics = getDiagnostics;
|
|
2296
|
-
this.isExpanded = false;
|
|
2297
|
-
this.container = document.createElement("div");
|
|
2298
|
-
this.container.className = "gallop-debug-overlay";
|
|
2299
|
-
this.container.setAttribute("aria-hidden", "true");
|
|
2300
|
-
this.container.style.cssText = `
|
|
2301
|
-
position: absolute;
|
|
2302
|
-
top: 10px;
|
|
2303
|
-
right: 10px;
|
|
2304
|
-
z-index: 9999;
|
|
2305
|
-
background: rgba(0, 0, 0, 0.8);
|
|
2306
|
-
color: #00ff00;
|
|
2307
|
-
font-family: monospace;
|
|
2308
|
-
font-size: 10px;
|
|
2309
|
-
padding: 5px;
|
|
2310
|
-
border-radius: 3px;
|
|
2311
|
-
pointer-events: auto;
|
|
2312
|
-
max-width: 300px;
|
|
2313
|
-
max-height: 80%;
|
|
2314
|
-
overflow-y: auto;
|
|
2315
|
-
border: 1px solid #333;
|
|
2316
|
-
`;
|
|
2317
|
-
this.contentEl = document.createElement("div");
|
|
2318
|
-
this.container.appendChild(this.contentEl);
|
|
2319
|
-
const toggleBtn = document.createElement("div");
|
|
2320
|
-
toggleBtn.textContent = "[DEBUG]";
|
|
2321
|
-
toggleBtn.style.cursor = "pointer";
|
|
2322
|
-
toggleBtn.onclick = () => this.toggle();
|
|
2323
|
-
this.container.insertBefore(toggleBtn, this.contentEl);
|
|
2324
|
-
this.parent.appendChild(this.container);
|
|
2325
|
-
this.update();
|
|
2326
|
-
setInterval(() => this.update(), 1e3);
|
|
2327
|
-
}
|
|
2328
|
-
toggle() {
|
|
2329
|
-
this.isExpanded = !this.isExpanded;
|
|
2330
|
-
this.container.setAttribute("aria-hidden", (!this.isExpanded).toString());
|
|
2331
|
-
if (this.isExpanded) {
|
|
2332
|
-
this.container.setAttribute("role", "log");
|
|
2333
|
-
} else {
|
|
2334
|
-
this.container.removeAttribute("role");
|
|
2335
|
-
}
|
|
2336
|
-
this.update();
|
|
2337
|
-
}
|
|
2338
|
-
update() {
|
|
2339
|
-
if (!this.isExpanded) {
|
|
2340
|
-
this.contentEl.innerHTML = "";
|
|
2341
|
-
this.container.style.width = "auto";
|
|
2342
|
-
return;
|
|
2343
|
-
}
|
|
2344
|
-
const diag = this.getDiagnostics();
|
|
2345
|
-
const html = `
|
|
2346
|
-
<div style="margin-top: 5px; border-top: 1px solid #444; padding-top: 5px;">
|
|
2347
|
-
<strong>System</strong><br>
|
|
2348
|
-
v: ${this.config.version}<br>
|
|
2349
|
-
mode: ${this.config.mode}<br>
|
|
2350
|
-
engine: ${this.config.engineType}<br>
|
|
2351
|
-
nonce: ${this.config.nonceStatus}<br>
|
|
2352
|
-
csp: ${this.config.cspStatus}<br>
|
|
2353
|
-
conn: ${this.config.connectionState || "n/a"}<br>
|
|
2354
|
-
</div>
|
|
2355
|
-
<div style="margin-top: 5px; border-top: 1px solid #444; padding-top: 5px;">
|
|
2356
|
-
<strong>Performance</strong><br>
|
|
2357
|
-
bitrate: ${(diag.bitrate / 1e6).toFixed(2)} Mbps<br>
|
|
2358
|
-
buffer: ${diag.bufferLength.toFixed(1)}s<br>
|
|
2359
|
-
fps: ${diag.fps}<br>
|
|
2360
|
-
dropped: ${diag.droppedFrames}<br>
|
|
2361
|
-
</div>
|
|
2362
|
-
<div style="margin-top: 5px; border-top: 1px solid #444; padding-top: 5px;">
|
|
2363
|
-
<button onclick="navigator.clipboard.writeText(JSON.stringify(window.GallopDiagnostics))" style="font-size: 9px; cursor: pointer;">
|
|
2364
|
-
Copy JSON Diagnostics
|
|
2365
|
-
</button>
|
|
2366
|
-
</div>
|
|
2367
|
-
`;
|
|
2368
|
-
this.contentEl.innerHTML = html;
|
|
2369
|
-
window.GallopDiagnostics = diag;
|
|
2370
|
-
}
|
|
2371
|
-
destroy() {
|
|
2372
|
-
this.container.remove();
|
|
2373
|
-
}
|
|
2374
|
-
};
|
|
2375
|
-
|
|
2376
|
-
// src/version.ts
|
|
2377
|
-
var GALLOP_VERSION = "0.0.1";
|
|
2378
|
-
|
|
2379
|
-
// src/core/GallopPlayerCore.ts
|
|
2380
|
-
var GallopPlayerCore = class extends chunk2JQGJ7NX_cjs.EventEmitter {
|
|
2381
|
-
constructor(container, config = {}) {
|
|
2382
|
-
super();
|
|
2383
|
-
this.engine = null;
|
|
2384
|
-
this.client = null;
|
|
2385
|
-
this.controls = null;
|
|
2386
|
-
this.bigPlayButton = null;
|
|
2387
|
-
this.loadingSpinner = null;
|
|
2388
|
-
this.errorOverlay = null;
|
|
2389
|
-
this.posterImage = null;
|
|
2390
|
-
this.contextMenu = null;
|
|
2391
|
-
this.keyboardManager = null;
|
|
2392
|
-
this.touchManager = null;
|
|
2393
|
-
this.analyticsCollector = null;
|
|
2394
|
-
this.styleEl = null;
|
|
2395
|
-
this.debugOverlay = null;
|
|
2396
|
-
this.cspStatus = "pending";
|
|
2397
|
-
this.nonceStatus = "none";
|
|
2398
|
-
this.destroyed = false;
|
|
2399
|
-
this.engineStats = null;
|
|
2400
|
-
this.container = container;
|
|
2401
|
-
this.config = config;
|
|
2402
|
-
this.state = new PlayerState((status) => {
|
|
2403
|
-
this.emit("statuschange", { status });
|
|
2404
|
-
this.updateUIState();
|
|
2405
|
-
});
|
|
2406
|
-
this.themeManager = new ThemeManager({ ...DEFAULT_THEME, ...config.theme });
|
|
2407
|
-
if (config.apiKey || config.embedToken) {
|
|
2408
|
-
this.client = new ScaleMuleClient({
|
|
2409
|
-
apiKey: config.apiKey,
|
|
2410
|
-
embedToken: config.embedToken,
|
|
2411
|
-
baseUrl: config.apiBaseUrl
|
|
2412
|
-
});
|
|
2413
|
-
}
|
|
2414
|
-
this.createDOM();
|
|
2415
|
-
this.bindVideoEvents();
|
|
2416
|
-
if (config.controls !== false) {
|
|
2417
|
-
this.mountUI();
|
|
2418
|
-
}
|
|
2419
|
-
if (config.keyboard !== false) {
|
|
2420
|
-
this.keyboardManager = new KeyboardManager(this);
|
|
2421
|
-
}
|
|
2422
|
-
if (config.touch !== false) {
|
|
2423
|
-
this.touchManager = new TouchManager(this, this.wrapper);
|
|
2424
|
-
}
|
|
2425
|
-
this.initializeAnalytics();
|
|
2426
|
-
if (this.config.debug) {
|
|
2427
|
-
this.initializeDebugOverlay();
|
|
2428
|
-
}
|
|
2429
|
-
if (config.videoId && this.client) {
|
|
2430
|
-
void this.loadVideoById(config.videoId);
|
|
2431
|
-
} else if (config.src) {
|
|
2432
|
-
void this.loadSource(config.src);
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
// --- DOM Setup ---
|
|
2436
|
-
createDOM() {
|
|
2437
|
-
this.styleEl = document.createElement("style");
|
|
2438
|
-
const nonce = resolveNonce(this.config.nonce);
|
|
2439
|
-
if (nonce) {
|
|
2440
|
-
this.styleEl.nonce = nonce;
|
|
2441
|
-
this.nonceStatus = this.config.nonce ? "explicit" : "auto";
|
|
2442
|
-
}
|
|
2443
|
-
this.styleEl.textContent = PLAYER_STYLES;
|
|
2444
|
-
this.container.appendChild(this.styleEl);
|
|
2445
|
-
detectCSPFailure(this.styleEl, (msg) => {
|
|
2446
|
-
this.cspStatus = "blocked";
|
|
2447
|
-
this.emit("error", { code: "CSP_BLOCKED", message: msg });
|
|
2448
|
-
});
|
|
2449
|
-
if (this.cspStatus === "pending") {
|
|
2450
|
-
this.cspStatus = "applied";
|
|
2451
|
-
}
|
|
2452
|
-
this.wrapper = document.createElement("div");
|
|
2453
|
-
this.wrapper.className = "gallop-player";
|
|
2454
|
-
this.wrapper.setAttribute("tabindex", "0");
|
|
2455
|
-
this.themeManager.apply(this.wrapper);
|
|
2456
|
-
const aspectRatio = this.config.aspectRatio ?? DEFAULT_CONFIG.aspectRatio;
|
|
2457
|
-
const [w, h] = aspectRatio.split(":").map(Number);
|
|
2458
|
-
if (w && h) {
|
|
2459
|
-
this.wrapper.style.aspectRatio = `${w} / ${h}`;
|
|
2460
|
-
}
|
|
2461
|
-
this.video = document.createElement("video");
|
|
2462
|
-
this.video.className = "gallop-video";
|
|
2463
|
-
this.video.playsInline = true;
|
|
2464
|
-
this.video.preload = "metadata";
|
|
2465
|
-
if (this.config.muted ?? DEFAULT_CONFIG.muted) {
|
|
2466
|
-
this.video.muted = true;
|
|
2467
|
-
}
|
|
2468
|
-
if (this.config.loop ?? DEFAULT_CONFIG.loop) {
|
|
2469
|
-
this.video.loop = true;
|
|
2470
|
-
}
|
|
2471
|
-
this.wrapper.appendChild(this.video);
|
|
2472
|
-
this.container.appendChild(this.wrapper);
|
|
2473
|
-
}
|
|
2474
|
-
mountUI() {
|
|
2475
|
-
this.posterImage = new PosterImage();
|
|
2476
|
-
this.wrapper.appendChild(this.posterImage.element);
|
|
2477
|
-
this.bigPlayButton = new BigPlayButton(() => this.togglePlay());
|
|
2478
|
-
this.wrapper.appendChild(this.bigPlayButton.element);
|
|
2479
|
-
this.loadingSpinner = new LoadingSpinner();
|
|
2480
|
-
this.wrapper.appendChild(this.loadingSpinner.element);
|
|
2481
|
-
this.errorOverlay = new ErrorOverlay(() => this.retry());
|
|
2482
|
-
this.wrapper.appendChild(this.errorOverlay.element);
|
|
2483
|
-
this.controls = new Controls(this, this.wrapper);
|
|
2484
|
-
this.wrapper.appendChild(this.controls.element);
|
|
2485
|
-
this.contextMenu = new ContextMenu(this.wrapper, this.config.pageUrl);
|
|
2486
|
-
this.wrapper.appendChild(this.contextMenu.element);
|
|
2487
|
-
const brand = document.createElement("div");
|
|
2488
|
-
brand.className = "gallop-brand";
|
|
2489
|
-
const dot = document.createElement("span");
|
|
2490
|
-
dot.className = "gallop-brand-dot";
|
|
2491
|
-
brand.appendChild(dot);
|
|
2492
|
-
brand.appendChild(document.createTextNode("Gallop"));
|
|
2493
|
-
this.wrapper.appendChild(brand);
|
|
2494
|
-
if (this.config.poster) {
|
|
2495
|
-
this.posterImage.show(this.config.poster);
|
|
2496
|
-
}
|
|
2497
|
-
}
|
|
2498
|
-
bindVideoEvents() {
|
|
2499
|
-
const v = this.video;
|
|
2500
|
-
v.addEventListener("play", () => {
|
|
2501
|
-
this.state.transition("playing");
|
|
2502
|
-
this.emit("play");
|
|
2503
|
-
});
|
|
2504
|
-
v.addEventListener("pause", () => {
|
|
2505
|
-
if (!this.state.isEnded) {
|
|
2506
|
-
this.state.transition("paused");
|
|
2507
|
-
this.emit("pause");
|
|
2508
|
-
}
|
|
2509
|
-
});
|
|
2510
|
-
v.addEventListener("ended", () => {
|
|
2511
|
-
this.state.transition("ended");
|
|
2512
|
-
this.emit("ended");
|
|
2513
|
-
});
|
|
2514
|
-
v.addEventListener("timeupdate", () => {
|
|
2515
|
-
this.emit("timeupdate", {
|
|
2516
|
-
currentTime: v.currentTime,
|
|
2517
|
-
duration: v.duration
|
|
2518
|
-
});
|
|
2519
|
-
});
|
|
2520
|
-
v.addEventListener("volumechange", () => {
|
|
2521
|
-
this.emit("volumechange", {
|
|
2522
|
-
volume: v.volume,
|
|
2523
|
-
muted: v.muted
|
|
2524
|
-
});
|
|
2525
|
-
});
|
|
2526
|
-
v.addEventListener("waiting", () => {
|
|
2527
|
-
this.state.transition("buffering");
|
|
2528
|
-
this.emit("buffering", { isBuffering: true });
|
|
2529
|
-
});
|
|
2530
|
-
v.addEventListener("playing", () => {
|
|
2531
|
-
this.state.transition("playing");
|
|
2532
|
-
this.emit("buffering", { isBuffering: false });
|
|
2533
|
-
});
|
|
2534
|
-
v.addEventListener("canplay", () => {
|
|
2535
|
-
if (this.state.status === "loading") {
|
|
2536
|
-
this.state.transition("ready");
|
|
2537
|
-
this.emit("ready");
|
|
2538
|
-
if (this.config.autoplay ?? DEFAULT_CONFIG.autoplay) {
|
|
2539
|
-
this.play();
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
});
|
|
2543
|
-
v.addEventListener("error", () => {
|
|
2544
|
-
const err = v.error;
|
|
2545
|
-
this.state.transition("error");
|
|
2546
|
-
this.emit("error", {
|
|
2547
|
-
code: `MEDIA_ERR_${err?.code ?? 0}`,
|
|
2548
|
-
message: err?.message ?? "Playback error"
|
|
2549
|
-
});
|
|
2550
|
-
});
|
|
2551
|
-
v.addEventListener("ratechange", () => {
|
|
2552
|
-
this.emit("ratechange", { rate: v.playbackRate });
|
|
2553
|
-
});
|
|
2554
|
-
}
|
|
2555
|
-
// --- Loading ---
|
|
2556
|
-
async loadVideoById(videoId) {
|
|
2557
|
-
if (!this.client) {
|
|
2558
|
-
this.emit("error", { code: "NO_API_KEY", message: "API key required to load video by ID" });
|
|
2559
|
-
return;
|
|
2560
|
-
}
|
|
2561
|
-
this.state.transition("loading");
|
|
2562
|
-
this.analyticsCollector?.setVideoId(videoId);
|
|
2563
|
-
try {
|
|
2564
|
-
const metadata = await this.client.getVideoMetadata(videoId);
|
|
2565
|
-
if (metadata.poster && this.posterImage) {
|
|
2566
|
-
this.posterImage.show(metadata.poster);
|
|
2567
|
-
}
|
|
2568
|
-
this.loadSource(metadata.playlistUrl);
|
|
2569
|
-
} catch (err) {
|
|
2570
|
-
this.state.transition("error");
|
|
2571
|
-
this.emit("error", {
|
|
2572
|
-
code: "API_ERROR",
|
|
2573
|
-
message: err instanceof Error ? err.message : "Failed to load video"
|
|
2574
|
-
});
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
loadSource(url) {
|
|
2578
|
-
this.state.transition("loading");
|
|
2579
|
-
try {
|
|
2580
|
-
this.engine?.destroy();
|
|
2581
|
-
this.engine = createEngine(
|
|
2582
|
-
{ apiKey: this.config.apiKey, embedToken: this.config.embedToken },
|
|
2583
|
-
this.config.hlsConfig
|
|
2584
|
-
);
|
|
2585
|
-
this.engine.on("qualitylevels", (levels) => {
|
|
2586
|
-
this.emit("qualitylevels", { levels });
|
|
2587
|
-
});
|
|
2588
|
-
this.engine.on("qualitychange", (level) => {
|
|
2589
|
-
this.emit("qualitychange", { level });
|
|
2590
|
-
});
|
|
2591
|
-
this.engine.on("error", (err) => {
|
|
2592
|
-
const e = err;
|
|
2593
|
-
this.state.transition("error");
|
|
2594
|
-
this.emit("error", e);
|
|
2595
|
-
});
|
|
2596
|
-
this.engine.on("stats", (stats) => {
|
|
2597
|
-
const s = stats;
|
|
2598
|
-
this.engineStats = s;
|
|
2599
|
-
this.analyticsCollector?.onEngineStats(s);
|
|
2600
|
-
this.emit("enginestats", { stats: s });
|
|
2601
|
-
});
|
|
2602
|
-
this.engine.load(url, this.video);
|
|
2603
|
-
if (this.config.startTime) {
|
|
2604
|
-
this.video.currentTime = this.config.startTime;
|
|
2605
|
-
}
|
|
2606
|
-
return Promise.resolve();
|
|
2607
|
-
} catch (err) {
|
|
2608
|
-
this.state.transition("error");
|
|
2609
|
-
this.emit("error", {
|
|
2610
|
-
code: "ENGINE_ERROR",
|
|
2611
|
-
message: err instanceof Error ? err.message : "Failed to initialize player"
|
|
2612
|
-
});
|
|
2613
|
-
return Promise.reject(err);
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
|
-
// --- Diagnostics & Debug ---
|
|
2617
|
-
getDiagnostics() {
|
|
2618
|
-
return {
|
|
2619
|
-
version: GALLOP_VERSION,
|
|
2620
|
-
mode: "inline",
|
|
2621
|
-
engineType: this.engine?.constructor.name || "none",
|
|
2622
|
-
bitrate: this.engineStats?.bandwidthEstimate || 0,
|
|
2623
|
-
bufferLength: this.getBufferLength(),
|
|
2624
|
-
fps: this.engineStats?.totalFrames || 0,
|
|
2625
|
-
// Simplified
|
|
2626
|
-
droppedFrames: this.engineStats?.droppedFrames || 0,
|
|
2627
|
-
totalFrames: this.engineStats?.totalFrames || 0,
|
|
2628
|
-
status: this.state.status,
|
|
2629
|
-
isMuted: this.video.muted,
|
|
2630
|
-
volume: this.video.volume,
|
|
2631
|
-
playbackRate: this.video.playbackRate,
|
|
2632
|
-
currentTime: this.video.currentTime,
|
|
2633
|
-
duration: this.video.duration,
|
|
2634
|
-
nonceStatus: this.nonceStatus,
|
|
2635
|
-
cspStatus: this.cspStatus
|
|
2636
|
-
};
|
|
2637
|
-
}
|
|
2638
|
-
getBufferLength() {
|
|
2639
|
-
const time = this.video.currentTime;
|
|
2640
|
-
const buffered = this.video.buffered;
|
|
2641
|
-
for (let i = 0; i < buffered.length; i++) {
|
|
2642
|
-
if (time >= buffered.start(i) && time <= buffered.end(i)) {
|
|
2643
|
-
return buffered.end(i) - time;
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
return 0;
|
|
2647
|
-
}
|
|
2648
|
-
async query(key) {
|
|
2649
|
-
switch (key) {
|
|
2650
|
-
case "currentTime":
|
|
2651
|
-
return this.video.currentTime;
|
|
2652
|
-
case "duration":
|
|
2653
|
-
return this.video.duration;
|
|
2654
|
-
case "volume":
|
|
2655
|
-
return this.video.volume;
|
|
2656
|
-
case "muted":
|
|
2657
|
-
return this.video.muted;
|
|
2658
|
-
case "playbackRate":
|
|
2659
|
-
return this.video.playbackRate;
|
|
2660
|
-
case "status":
|
|
2661
|
-
return this.state.status;
|
|
2662
|
-
case "isFullscreen":
|
|
2663
|
-
return this.isFullscreen;
|
|
2664
|
-
case "currentQuality":
|
|
2665
|
-
return this.getCurrentQuality();
|
|
2666
|
-
case "qualityLevels":
|
|
2667
|
-
return this.getQualityLevels();
|
|
2668
|
-
case "diagnostics":
|
|
2669
|
-
return this.getDiagnostics();
|
|
2670
|
-
default:
|
|
2671
|
-
return Promise.reject(new Error(`Unknown query key: ${key}`));
|
|
2672
|
-
}
|
|
2673
|
-
}
|
|
2674
|
-
initializeDebugOverlay() {
|
|
2675
|
-
const diag = this.getDiagnostics();
|
|
2676
|
-
this.debugOverlay = new DebugOverlay(
|
|
2677
|
-
this.wrapper,
|
|
2678
|
-
{
|
|
2679
|
-
version: diag.version,
|
|
2680
|
-
mode: diag.mode,
|
|
2681
|
-
engineType: diag.engineType,
|
|
2682
|
-
nonceStatus: diag.nonceStatus,
|
|
2683
|
-
cspStatus: diag.cspStatus
|
|
2684
|
-
},
|
|
2685
|
-
() => this.getDiagnostics()
|
|
2686
|
-
);
|
|
2687
|
-
}
|
|
2688
|
-
retry() {
|
|
2689
|
-
if (this.config.videoId && this.client) {
|
|
2690
|
-
void this.loadVideoById(this.config.videoId);
|
|
2691
|
-
} else if (this.config.src) {
|
|
2692
|
-
void this.loadSource(this.config.src);
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
// --- Playback Controls ---
|
|
2696
|
-
async play() {
|
|
2697
|
-
try {
|
|
2698
|
-
this.posterImage?.hide();
|
|
2699
|
-
await this.video.play();
|
|
2700
|
-
} catch (err) {
|
|
2701
|
-
if (err instanceof Error) {
|
|
2702
|
-
if (err.name === "NotAllowedError") {
|
|
2703
|
-
this.analyticsCollector?.trackEvent("error", this.currentTime, {
|
|
2704
|
-
event_subtype: "autoplay_blocked",
|
|
2705
|
-
error_code: "AUTOPLAY_BLOCKED",
|
|
2706
|
-
error_message: err.message
|
|
2707
|
-
});
|
|
2708
|
-
}
|
|
2709
|
-
if (err.name !== "AbortError") {
|
|
2710
|
-
this.emit("error", { code: "PLAY_FAILED", message: err.message });
|
|
2711
|
-
}
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
pause() {
|
|
2716
|
-
this.video.pause();
|
|
2717
|
-
return Promise.resolve();
|
|
2718
|
-
}
|
|
2719
|
-
togglePlay() {
|
|
2720
|
-
if (this.video.paused || this.video.ended) {
|
|
2721
|
-
return this.play();
|
|
2722
|
-
} else {
|
|
2723
|
-
return this.pause();
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
seek(time) {
|
|
2727
|
-
const t = clamp(time, 0, this.duration);
|
|
2728
|
-
this.video.currentTime = t;
|
|
2729
|
-
this.emit("seeked", { time: t });
|
|
2730
|
-
return Promise.resolve();
|
|
2731
|
-
}
|
|
2732
|
-
seekForward(seconds = 5) {
|
|
2733
|
-
void this.seek(this.currentTime + seconds);
|
|
2734
|
-
}
|
|
2735
|
-
seekBackward(seconds = 5) {
|
|
2736
|
-
void this.seek(this.currentTime - seconds);
|
|
2737
|
-
}
|
|
2738
|
-
// --- Volume ---
|
|
2739
|
-
get volume() {
|
|
2740
|
-
return this.video.volume;
|
|
2741
|
-
}
|
|
2742
|
-
set volume(v) {
|
|
2743
|
-
this.video.volume = clamp(v, 0, 1);
|
|
2744
|
-
}
|
|
2745
|
-
get muted() {
|
|
2746
|
-
return this.video.muted;
|
|
2747
|
-
}
|
|
2748
|
-
set muted(m) {
|
|
2749
|
-
this.video.muted = m;
|
|
2750
|
-
}
|
|
2751
|
-
toggleMute() {
|
|
2752
|
-
this.video.muted = !this.video.muted;
|
|
2753
|
-
}
|
|
2754
|
-
// --- Time ---
|
|
2755
|
-
get currentTime() {
|
|
2756
|
-
return this.video.currentTime;
|
|
2757
|
-
}
|
|
2758
|
-
get duration() {
|
|
2759
|
-
return this.video.duration || 0;
|
|
2760
|
-
}
|
|
2761
|
-
get paused() {
|
|
2762
|
-
return this.video.paused;
|
|
2763
|
-
}
|
|
2764
|
-
get buffered() {
|
|
2765
|
-
return this.video.buffered;
|
|
2766
|
-
}
|
|
2767
|
-
// --- Quality ---
|
|
2768
|
-
getQualityLevels() {
|
|
2769
|
-
return this.engine?.getQualityLevels() ?? [];
|
|
2770
|
-
}
|
|
2771
|
-
setQualityLevel(index) {
|
|
2772
|
-
this.engine?.setQualityLevel(index);
|
|
2773
|
-
return Promise.resolve();
|
|
2774
|
-
}
|
|
2775
|
-
setAutoQuality() {
|
|
2776
|
-
this.engine?.setAutoQuality();
|
|
2777
|
-
return Promise.resolve();
|
|
2778
|
-
}
|
|
2779
|
-
isAutoQuality() {
|
|
2780
|
-
return this.engine?.isAutoQuality() ?? true;
|
|
2781
|
-
}
|
|
2782
|
-
getCurrentQuality() {
|
|
2783
|
-
return this.engine?.getCurrentQuality() ?? -1;
|
|
2784
|
-
}
|
|
2785
|
-
// --- Playback Rate ---
|
|
2786
|
-
get playbackRate() {
|
|
2787
|
-
return this.video.playbackRate;
|
|
2788
|
-
}
|
|
2789
|
-
set playbackRate(rate) {
|
|
2790
|
-
if (SPEED_PRESETS.includes(rate)) {
|
|
2791
|
-
this.video.playbackRate = rate;
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
// --- Fullscreen ---
|
|
2795
|
-
async toggleFullscreen() {
|
|
2796
|
-
const fsEl = document.fullscreenElement ?? document.webkitFullscreenElement;
|
|
2797
|
-
if (fsEl) {
|
|
2798
|
-
await this.exitFullscreen();
|
|
2799
|
-
} else {
|
|
2800
|
-
await this.enterFullscreen();
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
async enterFullscreen() {
|
|
2804
|
-
const el2 = this.wrapper;
|
|
2805
|
-
try {
|
|
2806
|
-
if (el2.requestFullscreen) {
|
|
2807
|
-
await el2.requestFullscreen();
|
|
2808
|
-
} else if (el2.webkitRequestFullscreen) {
|
|
2809
|
-
await el2.webkitRequestFullscreen();
|
|
2810
|
-
}
|
|
2811
|
-
this.wrapper.classList.add("gallop-fullscreen");
|
|
2812
|
-
this.emit("fullscreenchange", { isFullscreen: true });
|
|
2813
|
-
} catch {
|
|
2814
|
-
}
|
|
2815
|
-
}
|
|
2816
|
-
async exitFullscreen() {
|
|
2817
|
-
const doc = document;
|
|
2818
|
-
try {
|
|
2819
|
-
if (document.exitFullscreen) {
|
|
2820
|
-
await document.exitFullscreen();
|
|
2821
|
-
} else if (doc.webkitExitFullscreen) {
|
|
2822
|
-
await doc.webkitExitFullscreen();
|
|
2823
|
-
}
|
|
2824
|
-
this.wrapper.classList.remove("gallop-fullscreen");
|
|
2825
|
-
this.emit("fullscreenchange", { isFullscreen: false });
|
|
2826
|
-
} catch {
|
|
2827
|
-
}
|
|
2828
|
-
}
|
|
2829
|
-
get isFullscreen() {
|
|
2830
|
-
const fsEl = document.fullscreenElement ?? document.webkitFullscreenElement;
|
|
2831
|
-
return fsEl === this.wrapper;
|
|
2832
|
-
}
|
|
2833
|
-
// --- State ---
|
|
2834
|
-
get status() {
|
|
2835
|
-
return this.state.status;
|
|
2836
|
-
}
|
|
2837
|
-
getVideoElement() {
|
|
2838
|
-
return this.video;
|
|
2839
|
-
}
|
|
2840
|
-
getWrapperElement() {
|
|
2841
|
-
return this.wrapper;
|
|
2842
|
-
}
|
|
2843
|
-
// --- UI Updates ---
|
|
2844
|
-
updateUIState() {
|
|
2845
|
-
const status = this.state.status;
|
|
2846
|
-
this.bigPlayButton?.setVisible(status === "idle" || status === "ready" || status === "paused" || status === "ended");
|
|
2847
|
-
this.loadingSpinner?.setVisible(status === "loading" || status === "buffering");
|
|
2848
|
-
this.errorOverlay?.setVisible(status === "error");
|
|
2849
|
-
if (status === "playing") {
|
|
2850
|
-
this.posterImage?.hide();
|
|
2851
|
-
}
|
|
2852
|
-
this.wrapper.setAttribute("data-status", status);
|
|
2853
|
-
}
|
|
2854
|
-
// --- Cleanup ---
|
|
2855
|
-
destroy() {
|
|
2856
|
-
if (this.destroyed) return;
|
|
2857
|
-
this.destroyed = true;
|
|
2858
|
-
this.keyboardManager?.destroy();
|
|
2859
|
-
this.touchManager?.destroy();
|
|
2860
|
-
this.contextMenu?.destroy();
|
|
2861
|
-
this.controls?.destroy();
|
|
2862
|
-
void this.analyticsCollector?.destroy();
|
|
2863
|
-
this.engine?.destroy();
|
|
2864
|
-
this.state.reset();
|
|
2865
|
-
this.emit("destroy");
|
|
2866
|
-
this.removeAllListeners();
|
|
2867
|
-
this.wrapper.remove();
|
|
2868
|
-
this.styleEl?.remove();
|
|
2869
|
-
}
|
|
2870
|
-
initializeAnalytics() {
|
|
2871
|
-
const analyticsConfig = this.config.analytics;
|
|
2872
|
-
const enabled = analyticsConfig?.enabled ?? true;
|
|
2873
|
-
if (!enabled || !this.client) {
|
|
2874
|
-
return;
|
|
2875
|
-
}
|
|
2876
|
-
const videoId = analyticsConfig?.videoId ?? this.config.videoId;
|
|
2877
|
-
if (!videoId) {
|
|
2878
|
-
if (analyticsConfig?.debug) {
|
|
2879
|
-
console.warn("[Gallop analytics] disabled: no videoId available");
|
|
2880
|
-
}
|
|
2881
|
-
return;
|
|
2882
|
-
}
|
|
2883
|
-
this.analyticsCollector = new AnalyticsCollector({
|
|
2884
|
-
client: this.client,
|
|
2885
|
-
player: this,
|
|
2886
|
-
videoId,
|
|
2887
|
-
config: analyticsConfig
|
|
2888
|
-
});
|
|
2889
|
-
}
|
|
2890
|
-
};
|
|
2891
|
-
|
|
2892
|
-
exports.GALLOP_VERSION = GALLOP_VERSION;
|
|
2893
|
-
exports.GallopPlayerCore = GallopPlayerCore;
|