@sweet-player/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1551 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ SweetPlayer: () => SweetPlayer,
34
+ registerLocale: () => registerLocale
35
+ });
36
+ module.exports = __toCommonJS(src_exports);
37
+
38
+ // src/core/events.ts
39
+ var EventEmitter = class {
40
+ constructor() {
41
+ this.listeners = /* @__PURE__ */ new Map();
42
+ }
43
+ on(event, fn) {
44
+ let set = this.listeners.get(event);
45
+ if (!set) {
46
+ set = /* @__PURE__ */ new Set();
47
+ this.listeners.set(event, set);
48
+ }
49
+ set.add(fn);
50
+ return () => this.off(event, fn);
51
+ }
52
+ off(event, fn) {
53
+ this.listeners.get(event)?.delete(fn);
54
+ }
55
+ emit(event, payload) {
56
+ this.listeners.get(event)?.forEach((fn) => fn(payload));
57
+ }
58
+ removeAll() {
59
+ this.listeners.clear();
60
+ }
61
+ };
62
+
63
+ // src/core/media.ts
64
+ var import_hls = __toESM(require("hls.js"), 1);
65
+ function isHlsSource(src) {
66
+ return /\.m3u8($|\?)/i.test(src);
67
+ }
68
+ var MediaController = class {
69
+ constructor(video, emitter, hlsConfig, callbacks = {}) {
70
+ this.video = video;
71
+ this.emitter = emitter;
72
+ this.hlsConfig = hlsConfig;
73
+ this.callbacks = callbacks;
74
+ this.hls = null;
75
+ /** 最近一次加载的地址,错误重试用 */
76
+ this.currentSrc = "";
77
+ }
78
+ load(src) {
79
+ this.detachHls();
80
+ this.currentSrc = src;
81
+ if (isHlsSource(src)) {
82
+ if (import_hls.default.isSupported()) {
83
+ this.hls = new import_hls.default(this.hlsConfig);
84
+ this.hls.on(import_hls.default.Events.ERROR, (_event, data) => {
85
+ if (data.fatal) {
86
+ switch (data.type) {
87
+ case import_hls.default.ErrorTypes.NETWORK_ERROR:
88
+ this.emitter.emit("error", { type: "hls-network", detail: data });
89
+ break;
90
+ case import_hls.default.ErrorTypes.MEDIA_ERROR:
91
+ this.hls?.recoverMediaError();
92
+ break;
93
+ default:
94
+ this.emitter.emit("error", { type: "hls-fatal", detail: data });
95
+ this.detachHls();
96
+ }
97
+ }
98
+ });
99
+ this.hls.on(import_hls.default.Events.MANIFEST_PARSED, () => {
100
+ this.notifyTracks();
101
+ });
102
+ this.hls.loadSource(src);
103
+ this.hls.attachMedia(this.video);
104
+ return;
105
+ }
106
+ if (this.video.canPlayType("application/vnd.apple.mpegurl")) {
107
+ this.video.src = src;
108
+ return;
109
+ }
110
+ this.emitter.emit("error", { type: "hls-unsupported" });
111
+ return;
112
+ }
113
+ this.video.src = src;
114
+ }
115
+ /** 重新加载当前地址(错误重试) */
116
+ reload() {
117
+ if (this.currentSrc) this.load(this.currentSrc);
118
+ }
119
+ notifyTracks() {
120
+ if (!this.hls) return;
121
+ if (this.hls.levels.length > 1 && this.callbacks.onLevels) {
122
+ const levels = this.hls.levels.map((l, i) => ({
123
+ index: i,
124
+ height: l.height,
125
+ bitrate: l.bitrate,
126
+ label: l.height ? `${l.height}P` : `${Math.round(l.bitrate / 1e3)}kbps`
127
+ })).sort((a, b) => b.height - a.height);
128
+ this.callbacks.onLevels(levels);
129
+ }
130
+ if (this.hls.audioTracks.length > 1 && this.callbacks.onAudioTracks) {
131
+ this.callbacks.onAudioTracks(
132
+ this.hls.audioTracks.map((t, i) => ({ index: i, label: t.name || t.lang || `Track ${i + 1}` }))
133
+ );
134
+ }
135
+ }
136
+ /** 切换 hls 画质档位,-1 为自动 */
137
+ setLevel(index) {
138
+ if (this.hls) this.hls.currentLevel = index;
139
+ }
140
+ setAudioTrack(index) {
141
+ if (this.hls) this.hls.audioTrack = index;
142
+ }
143
+ /** hls.js 带宽估算(bps),统计面板用 */
144
+ get bandwidthEstimate() {
145
+ return this.hls?.bandwidthEstimate;
146
+ }
147
+ /** 当前 hls level 描述,统计面板用 */
148
+ get currentLevelInfo() {
149
+ if (!this.hls || this.hls.currentLevel < 0) return void 0;
150
+ const level = this.hls.levels[this.hls.currentLevel];
151
+ if (!level) return void 0;
152
+ return `${level.width}x${level.height}@${Math.round(level.bitrate / 1e3)}kbps`;
153
+ }
154
+ detachHls() {
155
+ if (this.hls) {
156
+ this.hls.destroy();
157
+ this.hls = null;
158
+ }
159
+ }
160
+ destroy() {
161
+ this.detachHls();
162
+ this.video.removeAttribute("src");
163
+ this.video.load();
164
+ }
165
+ };
166
+
167
+ // src/core/keyboard.ts
168
+ var HOLD_THRESHOLD = 300;
169
+ var TICK_INTERVAL = 250;
170
+ function isEditableTarget(target) {
171
+ if (!(target instanceof HTMLElement)) return false;
172
+ return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable;
173
+ }
174
+ var KeyboardController = class {
175
+ constructor(container, actions, options) {
176
+ this.container = container;
177
+ this.actions = actions;
178
+ this.options = options;
179
+ this.holdDirection = 0;
180
+ this.holdStart = 0;
181
+ this.accumulated = 0;
182
+ this.tickTimer = null;
183
+ this.hovering = false;
184
+ this.onKeyDown = (e) => this.handleKeyDown(e);
185
+ this.onKeyUp = (e) => this.handleKeyUp(e);
186
+ this.onEnter = () => this.hovering = true;
187
+ this.onLeave = () => this.hovering = false;
188
+ document.addEventListener("keydown", this.onKeyDown);
189
+ document.addEventListener("keyup", this.onKeyUp);
190
+ container.addEventListener("mouseenter", this.onEnter);
191
+ container.addEventListener("mouseleave", this.onLeave);
192
+ }
193
+ isScoped(e) {
194
+ if (isEditableTarget(e.target)) return false;
195
+ return this.hovering || this.container.contains(document.activeElement);
196
+ }
197
+ handleKeyDown(e) {
198
+ if (!this.isScoped(e)) return;
199
+ switch (e.key) {
200
+ case " ":
201
+ e.preventDefault();
202
+ if (!e.repeat) this.actions.togglePlay();
203
+ break;
204
+ case "ArrowLeft":
205
+ case "ArrowRight": {
206
+ e.preventDefault();
207
+ if (e.repeat) return;
208
+ this.startHold(e.key === "ArrowRight" ? 1 : -1);
209
+ break;
210
+ }
211
+ case "ArrowUp":
212
+ e.preventDefault();
213
+ this.actions.adjustVolume(5);
214
+ break;
215
+ case "ArrowDown":
216
+ e.preventDefault();
217
+ this.actions.adjustVolume(-5);
218
+ break;
219
+ case "f":
220
+ case "F":
221
+ if (!e.repeat) this.actions.toggleFullscreen();
222
+ break;
223
+ case "m":
224
+ case "M":
225
+ if (!e.repeat) this.actions.toggleMute();
226
+ break;
227
+ }
228
+ }
229
+ handleKeyUp(e) {
230
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
231
+ const dir = e.key === "ArrowRight" ? 1 : -1;
232
+ if (this.holdDirection !== dir) return;
233
+ this.endHold();
234
+ }
235
+ startHold(direction) {
236
+ if (this.holdDirection !== 0) this.endHold();
237
+ this.holdDirection = direction;
238
+ this.holdStart = performance.now();
239
+ this.accumulated = 0;
240
+ this.tickTimer = setInterval(() => {
241
+ const heldMs = performance.now() - this.holdStart;
242
+ if (heldMs < HOLD_THRESHOLD) return;
243
+ const { longSeekSteps, stepUpInterval } = this.options;
244
+ const levelIndex = Math.min(
245
+ Math.floor((heldMs - HOLD_THRESHOLD) / stepUpInterval),
246
+ longSeekSteps.length - 1
247
+ );
248
+ const stepPerSecond = longSeekSteps[levelIndex];
249
+ this.accumulated += stepPerSecond * (TICK_INTERVAL / 1e3) * this.holdDirection;
250
+ this.actions.onLongSeekProgress(Math.round(this.accumulated));
251
+ }, TICK_INTERVAL);
252
+ }
253
+ endHold() {
254
+ if (this.tickTimer) {
255
+ clearInterval(this.tickTimer);
256
+ this.tickTimer = null;
257
+ }
258
+ const heldMs = performance.now() - this.holdStart;
259
+ const dir = this.holdDirection;
260
+ this.holdDirection = 0;
261
+ if (heldMs < HOLD_THRESHOLD) {
262
+ this.actions.seekBy(this.options.seekStep * dir);
263
+ } else {
264
+ this.actions.onLongSeekCommit(Math.round(this.accumulated));
265
+ }
266
+ this.accumulated = 0;
267
+ }
268
+ destroy() {
269
+ if (this.tickTimer) clearInterval(this.tickTimer);
270
+ document.removeEventListener("keydown", this.onKeyDown);
271
+ document.removeEventListener("keyup", this.onKeyUp);
272
+ this.container.removeEventListener("mouseenter", this.onEnter);
273
+ this.container.removeEventListener("mouseleave", this.onLeave);
274
+ }
275
+ };
276
+
277
+ // src/core/gestures.ts
278
+ var SWIPE_THRESHOLD = 12;
279
+ var DOUBLE_TAP_WINDOW = 280;
280
+ var SEEK_PER_WIDTH = 120;
281
+ var VOLUME_PER_HEIGHT = 100;
282
+ var GestureController = class {
283
+ constructor(container, video, actions, seekStep) {
284
+ this.container = container;
285
+ this.video = video;
286
+ this.actions = actions;
287
+ this.seekStep = seekStep;
288
+ this.startX = 0;
289
+ this.startY = 0;
290
+ this.mode = "none";
291
+ this.pendingDelta = 0;
292
+ this.lastTapTime = 0;
293
+ this.lastTapX = 0;
294
+ this.singleTapTimer = null;
295
+ this.onPointerDown = (e) => this.handleDown(e);
296
+ this.onPointerMove = (e) => this.handleMove(e);
297
+ this.onPointerUp = (e) => this.handleUp(e);
298
+ container.addEventListener("pointerdown", this.onPointerDown);
299
+ container.addEventListener("pointermove", this.onPointerMove);
300
+ container.addEventListener("pointerup", this.onPointerUp);
301
+ container.addEventListener("pointercancel", this.onPointerUp);
302
+ }
303
+ /** 手势只在视频画面区域生效,控件上的触摸不拦截 */
304
+ isOnVideoSurface(e) {
305
+ return e.target === this.video || e.target === this.container;
306
+ }
307
+ handleDown(e) {
308
+ if (e.pointerType !== "touch" || !this.isOnVideoSurface(e)) return;
309
+ this.startX = e.clientX;
310
+ this.startY = e.clientY;
311
+ this.mode = "none";
312
+ this.pendingDelta = 0;
313
+ }
314
+ handleMove(e) {
315
+ if (e.pointerType !== "touch" || this.mode === "none" && !this.isOnVideoSurface(e)) return;
316
+ const dx = e.clientX - this.startX;
317
+ const dy = e.clientY - this.startY;
318
+ const rect = this.container.getBoundingClientRect();
319
+ if (this.mode === "none") {
320
+ if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
321
+ this.mode = "seek";
322
+ } else if (Math.abs(dy) > SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
323
+ if (this.startX - rect.left > rect.width / 2) this.mode = "volume";
324
+ }
325
+ if (this.mode !== "none") this.container.setPointerCapture(e.pointerId);
326
+ }
327
+ if (this.mode === "seek") {
328
+ this.pendingDelta = Math.round(dx / rect.width * SEEK_PER_WIDTH);
329
+ this.actions.onSeekPreview(this.pendingDelta);
330
+ } else if (this.mode === "volume") {
331
+ const deltaVol = Math.round(-(e.clientY - this.startY) / rect.height * VOLUME_PER_HEIGHT);
332
+ if (deltaVol !== 0) {
333
+ this.actions.adjustVolume(deltaVol);
334
+ this.startY = e.clientY;
335
+ }
336
+ }
337
+ }
338
+ handleUp(e) {
339
+ if (e.pointerType !== "touch") return;
340
+ if (this.mode === "seek") {
341
+ this.actions.onSeekCommit(this.pendingDelta);
342
+ } else if (this.mode === "none" && this.isOnVideoSurface(e)) {
343
+ this.handleTap(e);
344
+ }
345
+ this.mode = "none";
346
+ this.pendingDelta = 0;
347
+ }
348
+ handleTap(e) {
349
+ const now = Date.now();
350
+ const rect = this.container.getBoundingClientRect();
351
+ if (now - this.lastTapTime < DOUBLE_TAP_WINDOW && Math.abs(e.clientX - this.lastTapX) < 60) {
352
+ if (this.singleTapTimer) {
353
+ clearTimeout(this.singleTapTimer);
354
+ this.singleTapTimer = null;
355
+ }
356
+ this.lastTapTime = 0;
357
+ const zone = (e.clientX - rect.left) / rect.width;
358
+ if (zone < 1 / 3) this.actions.seekBy(-this.seekStep);
359
+ else if (zone > 2 / 3) this.actions.seekBy(this.seekStep);
360
+ else this.actions.toggleFullscreen();
361
+ return;
362
+ }
363
+ this.lastTapTime = now;
364
+ this.lastTapX = e.clientX;
365
+ this.singleTapTimer = setTimeout(() => {
366
+ this.singleTapTimer = null;
367
+ this.actions.toggleControls();
368
+ }, DOUBLE_TAP_WINDOW);
369
+ }
370
+ destroy() {
371
+ if (this.singleTapTimer) clearTimeout(this.singleTapTimer);
372
+ this.container.removeEventListener("pointerdown", this.onPointerDown);
373
+ this.container.removeEventListener("pointermove", this.onPointerMove);
374
+ this.container.removeEventListener("pointerup", this.onPointerUp);
375
+ this.container.removeEventListener("pointercancel", this.onPointerUp);
376
+ }
377
+ };
378
+
379
+ // src/core/fullscreen.ts
380
+ function isFullscreen(el) {
381
+ const doc = document;
382
+ const current = doc.fullscreenElement ?? doc.webkitFullscreenElement;
383
+ return current === el;
384
+ }
385
+ async function toggleFullscreen(el) {
386
+ const doc = document;
387
+ const fsEl = el;
388
+ if (isFullscreen(el)) {
389
+ await (doc.exitFullscreen?.() ?? doc.webkitExitFullscreen?.());
390
+ } else {
391
+ await (fsEl.requestFullscreen?.() ?? fsEl.webkitRequestFullscreen?.());
392
+ }
393
+ }
394
+ function onFullscreenChange(el, cb) {
395
+ const handler = () => cb(isFullscreen(el));
396
+ document.addEventListener("fullscreenchange", handler);
397
+ document.addEventListener("webkitfullscreenchange", handler);
398
+ return () => {
399
+ document.removeEventListener("fullscreenchange", handler);
400
+ document.removeEventListener("webkitfullscreenchange", handler);
401
+ };
402
+ }
403
+
404
+ // src/utils/dom.ts
405
+ function createEl(tag, { className, text, html, attrs, parent } = {}) {
406
+ const el = document.createElement(tag);
407
+ if (className) el.className = className;
408
+ if (text) el.textContent = text;
409
+ if (html) el.innerHTML = html;
410
+ if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
411
+ if (parent) parent.appendChild(el);
412
+ return el;
413
+ }
414
+ function createSvgIcon(pathData, viewBox = "0 0 24 24") {
415
+ return `<svg viewBox="${viewBox}" fill="currentColor" aria-hidden="true"><path d="${pathData}"/></svg>`;
416
+ }
417
+
418
+ // src/utils/time.ts
419
+ function formatTime(seconds) {
420
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
421
+ const s = Math.floor(seconds % 60);
422
+ const m = Math.floor(seconds / 60 % 60);
423
+ const h = Math.floor(seconds / 3600);
424
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
425
+ const ss = String(s).padStart(2, "0");
426
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
427
+ }
428
+ function clamp(value, min, max) {
429
+ return Math.min(max, Math.max(min, value));
430
+ }
431
+
432
+ // src/ui/icons.ts
433
+ var icons = {
434
+ play: createSvgIcon("M8 5v14l11-7z"),
435
+ pause: createSvgIcon("M6 19h4V5H6v14zm8-14v14h4V5h-4z"),
436
+ prev: createSvgIcon("M6 6h2v12H6zm3.5 6l8.5 6V6z"),
437
+ next: createSvgIcon("M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"),
438
+ seekBack: createSvgIcon(
439
+ "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"
440
+ ),
441
+ seekForward: createSvgIcon(
442
+ "M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"
443
+ ),
444
+ volumeHigh: createSvgIcon(
445
+ "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
446
+ ),
447
+ volumeLow: createSvgIcon(
448
+ "M5 9v6h4l5 5V4L9 9H5zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
449
+ ),
450
+ volumeMute: createSvgIcon(
451
+ "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
452
+ ),
453
+ fullscreen: createSvgIcon(
454
+ "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
455
+ ),
456
+ fullscreenExit: createSvgIcon(
457
+ "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
458
+ ),
459
+ pip: createSvgIcon(
460
+ "M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"
461
+ ),
462
+ audio: createSvgIcon(
463
+ "M12 3a9 9 0 0 0-9 9v7c0 1.1.9 2 2 2h4v-8H5v-1c0-3.87 3.13-7 7-7s7 3.13 7 7v1h-4v8h4c1.1 0 2-.9 2-2v-7a9 9 0 0 0-9-9z"
464
+ )
465
+ };
466
+
467
+ // src/ui/components/menu.ts
468
+ function createPopupMenu(opts) {
469
+ const root = createEl("div", { className: "sp-menu" });
470
+ const btn = createEl("button", {
471
+ className: "sp-btn sp-menu-btn",
472
+ html: opts.buttonHtml,
473
+ attrs: { type: "button", title: opts.title, "aria-label": opts.title },
474
+ parent: root
475
+ });
476
+ const panel = createEl("div", { className: "sp-menu-panel", parent: root });
477
+ let items = [];
478
+ let activeValue;
479
+ function render() {
480
+ panel.innerHTML = "";
481
+ if (items.length === 0) {
482
+ createEl("div", { className: "sp-menu-empty", text: opts.emptyText ?? "-", parent: panel });
483
+ return;
484
+ }
485
+ for (const item of items) {
486
+ const itemBtn = createEl("button", {
487
+ className: "sp-menu-item" + (item.value === activeValue ? " sp-active" : ""),
488
+ text: item.label,
489
+ attrs: { type: "button" },
490
+ parent: panel
491
+ });
492
+ itemBtn.addEventListener("click", (e) => {
493
+ e.stopPropagation();
494
+ close();
495
+ opts.onSelect(item);
496
+ });
497
+ }
498
+ }
499
+ function close() {
500
+ root.classList.remove("sp-open");
501
+ }
502
+ btn.addEventListener("click", (e) => {
503
+ e.stopPropagation();
504
+ const willOpen = !root.classList.contains("sp-open");
505
+ root.closest(".sp-controls")?.querySelectorAll(".sp-menu.sp-open").forEach((m) => m.classList.remove("sp-open"));
506
+ root.classList.toggle("sp-open", willOpen);
507
+ });
508
+ document.addEventListener("click", close);
509
+ render();
510
+ return {
511
+ el: root,
512
+ setItems(next) {
513
+ items = next;
514
+ render();
515
+ },
516
+ setActive(value) {
517
+ activeValue = value;
518
+ render();
519
+ },
520
+ setButtonContent(html) {
521
+ btn.innerHTML = html;
522
+ },
523
+ close
524
+ };
525
+ }
526
+
527
+ // src/ui/components/progressBar.ts
528
+ function createProgressBar(video, onSeek) {
529
+ const root = createEl("div", { className: "sp-progress" });
530
+ const track = createEl("div", { className: "sp-progress-track", parent: root });
531
+ const buffered = createEl("div", { className: "sp-progress-buffered", parent: track });
532
+ const played = createEl("div", { className: "sp-progress-played", parent: track });
533
+ const thumb = createEl("div", { className: "sp-progress-thumb", parent: track });
534
+ const tooltip = createEl("div", { className: "sp-progress-tooltip", text: "0:00", parent: root });
535
+ let dragging = false;
536
+ function ratioFromEvent(e) {
537
+ const rect = root.getBoundingClientRect();
538
+ return clamp((e.clientX - rect.left) / rect.width, 0, 1);
539
+ }
540
+ function render(ratio) {
541
+ const duration = video.duration || 0;
542
+ const playedRatio = ratio ?? (duration ? video.currentTime / duration : 0);
543
+ played.style.width = `${playedRatio * 100}%`;
544
+ thumb.style.left = `${playedRatio * 100}%`;
545
+ let bufferedEnd = 0;
546
+ for (let i = 0; i < video.buffered.length; i++) {
547
+ if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) > bufferedEnd) {
548
+ bufferedEnd = video.buffered.end(i);
549
+ }
550
+ }
551
+ buffered.style.width = duration ? `${bufferedEnd / duration * 100}%` : "0%";
552
+ }
553
+ function onPointerMove(e) {
554
+ const ratio = ratioFromEvent(e);
555
+ tooltip.style.left = `${ratio * 100}%`;
556
+ tooltip.textContent = formatTime(ratio * (video.duration || 0));
557
+ if (dragging) render(ratio);
558
+ }
559
+ function onPointerDown(e) {
560
+ dragging = true;
561
+ root.classList.add("sp-dragging");
562
+ root.setPointerCapture(e.pointerId);
563
+ render(ratioFromEvent(e));
564
+ }
565
+ function onPointerUp(e) {
566
+ if (!dragging) return;
567
+ dragging = false;
568
+ root.classList.remove("sp-dragging");
569
+ onSeek(ratioFromEvent(e) * (video.duration || 0));
570
+ }
571
+ root.addEventListener("pointermove", onPointerMove);
572
+ root.addEventListener("pointerdown", onPointerDown);
573
+ root.addEventListener("pointerup", onPointerUp);
574
+ root.addEventListener("pointercancel", () => {
575
+ dragging = false;
576
+ root.classList.remove("sp-dragging");
577
+ });
578
+ return {
579
+ el: root,
580
+ update() {
581
+ if (!dragging) render();
582
+ },
583
+ destroy() {
584
+ root.remove();
585
+ }
586
+ };
587
+ }
588
+
589
+ // src/ui/components/volume.ts
590
+ function createVolumeControl(opts) {
591
+ const root = createEl("div", { className: "sp-volume" });
592
+ const btn = createEl("button", {
593
+ className: "sp-btn",
594
+ html: icons.volumeHigh,
595
+ attrs: { type: "button", title: opts.muteTitle, "aria-label": opts.muteTitle },
596
+ parent: root
597
+ });
598
+ const sliderWrap = createEl("div", { className: "sp-volume-slider", parent: root });
599
+ const track = createEl("div", { className: "sp-volume-track", parent: sliderWrap });
600
+ const fill = createEl("div", { className: "sp-volume-fill", parent: track });
601
+ const thumb = createEl("div", { className: "sp-volume-thumb", parent: track });
602
+ btn.addEventListener("click", opts.onToggleMute);
603
+ let dragging = false;
604
+ function volumeFromEvent(e) {
605
+ const rect = track.getBoundingClientRect();
606
+ return Math.round(clamp((e.clientX - rect.left) / rect.width, 0, 1) * 100);
607
+ }
608
+ track.addEventListener("pointerdown", (e) => {
609
+ dragging = true;
610
+ root.classList.add("sp-dragging");
611
+ track.setPointerCapture(e.pointerId);
612
+ opts.onVolumeChange(volumeFromEvent(e));
613
+ });
614
+ track.addEventListener("pointermove", (e) => {
615
+ if (dragging) opts.onVolumeChange(volumeFromEvent(e));
616
+ });
617
+ const endDrag = () => {
618
+ dragging = false;
619
+ root.classList.remove("sp-dragging");
620
+ };
621
+ track.addEventListener("pointerup", endDrag);
622
+ track.addEventListener("pointercancel", endDrag);
623
+ return {
624
+ el: root,
625
+ update(volume, muted) {
626
+ const shown = muted ? 0 : volume;
627
+ fill.style.width = `${shown}%`;
628
+ thumb.style.left = `${shown}%`;
629
+ btn.innerHTML = muted || volume === 0 ? icons.volumeMute : volume < 50 ? icons.volumeLow : icons.volumeHigh;
630
+ }
631
+ };
632
+ }
633
+
634
+ // src/ui/controls.ts
635
+ function createControls(ctx) {
636
+ const { video, actions, i18n } = ctx;
637
+ const topEl = createEl("div", { className: "sp-top" });
638
+ const titleEl = createEl("div", { className: "sp-title", text: ctx.title, parent: topEl });
639
+ titleEl.addEventListener("click", actions.onTitleClick);
640
+ const bottomEl = createEl("div", { className: "sp-bottom" });
641
+ const progress = createProgressBar(video, (t) => {
642
+ video.currentTime = t;
643
+ });
644
+ bottomEl.appendChild(progress.el);
645
+ const row = createEl("div", { className: "sp-controls", parent: bottomEl });
646
+ const button = (html, title, onClick, disabled = false) => {
647
+ const btn = createEl("button", {
648
+ className: "sp-btn",
649
+ html,
650
+ attrs: { type: "button", title, "aria-label": title },
651
+ parent: row
652
+ });
653
+ btn.disabled = disabled;
654
+ btn.addEventListener("click", onClick);
655
+ return btn;
656
+ };
657
+ button(icons.prev, i18n.t("prev"), () => actions.onPrev?.(), !actions.onPrev);
658
+ button(icons.seekBack, i18n.t("seekBack", { n: ctx.seekStep }), () => actions.seekBy(-ctx.seekStep));
659
+ const playBtn = button(icons.play, i18n.t("playPause"), actions.togglePlay);
660
+ button(icons.seekForward, i18n.t("seekForward", { n: ctx.seekStep }), () => actions.seekBy(ctx.seekStep));
661
+ button(icons.next, i18n.t("next"), () => actions.onNext?.(), !actions.onNext);
662
+ const timeEl = createEl("span", { className: "sp-time", text: "0:00 / 0:00", parent: row });
663
+ createEl("div", { className: "sp-controls-spacer", parent: row });
664
+ const rateMenu = createPopupMenu({
665
+ buttonHtml: "1x",
666
+ title: i18n.t("speed"),
667
+ emptyText: i18n.t("empty"),
668
+ onSelect: (item) => actions.setRate(item.value)
669
+ });
670
+ rateMenu.setItems(ctx.playbackRates.map((r) => ({ label: `${r}x`, value: r })));
671
+ rateMenu.setActive(video.playbackRate);
672
+ row.appendChild(rateMenu.el);
673
+ const qualityMenu = createPopupMenu({
674
+ buttonHtml: i18n.t("quality"),
675
+ title: i18n.t("quality"),
676
+ emptyText: i18n.t("empty"),
677
+ onSelect: (item) => actions.selectQuality(item.value)
678
+ });
679
+ row.appendChild(qualityMenu.el);
680
+ const ratioMenu = createPopupMenu({
681
+ buttonHtml: i18n.t("aspectRatio"),
682
+ title: i18n.t("aspectRatio"),
683
+ emptyText: i18n.t("empty"),
684
+ onSelect: (item) => actions.setAspectRatio(item.value)
685
+ });
686
+ ratioMenu.setItems(
687
+ ctx.aspectRatios.map((r) => ({ label: r === "original" ? i18n.t("ratioOriginal") : r, value: r }))
688
+ );
689
+ ratioMenu.setActive("original");
690
+ row.appendChild(ratioMenu.el);
691
+ const audioMenu = createPopupMenu({
692
+ buttonHtml: icons.audio,
693
+ title: i18n.t("audioTrack"),
694
+ emptyText: i18n.t("empty"),
695
+ onSelect: (item) => actions.selectAudioTrack(item.value)
696
+ });
697
+ row.appendChild(audioMenu.el);
698
+ const volume = createVolumeControl({
699
+ muteTitle: i18n.t("mute"),
700
+ onVolumeChange: actions.setVolume,
701
+ onToggleMute: actions.toggleMute
702
+ });
703
+ row.appendChild(volume.el);
704
+ if (document.pictureInPictureEnabled) {
705
+ button(icons.pip, i18n.t("pip"), actions.togglePip);
706
+ }
707
+ const fsBtn = button(icons.fullscreen, i18n.t("fullscreen"), actions.toggleFullscreen);
708
+ return {
709
+ topEl,
710
+ bottomEl,
711
+ progress,
712
+ volume,
713
+ qualityMenu,
714
+ audioMenu,
715
+ updatePlayState(playing) {
716
+ playBtn.innerHTML = playing ? icons.pause : icons.play;
717
+ },
718
+ updateTime() {
719
+ timeEl.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
720
+ progress.update();
721
+ },
722
+ updateRate(rate) {
723
+ rateMenu.setButtonContent(`${rate}x`);
724
+ rateMenu.setActive(rate);
725
+ },
726
+ updateFullscreen(fs) {
727
+ fsBtn.innerHTML = fs ? icons.fullscreenExit : icons.fullscreen;
728
+ },
729
+ updateRatio(ratio) {
730
+ ratioMenu.setActive(ratio);
731
+ },
732
+ destroy() {
733
+ topEl.remove();
734
+ bottomEl.remove();
735
+ }
736
+ };
737
+ }
738
+
739
+ // src/ui/components/osd.ts
740
+ function createOsd() {
741
+ const el = createEl("div", { className: "sp-osd" });
742
+ let hideTimer = null;
743
+ function show(text) {
744
+ if (hideTimer) {
745
+ clearTimeout(hideTimer);
746
+ hideTimer = null;
747
+ }
748
+ el.textContent = text;
749
+ el.classList.add("sp-visible");
750
+ }
751
+ function hide() {
752
+ el.classList.remove("sp-visible");
753
+ }
754
+ return {
755
+ el,
756
+ show,
757
+ hide,
758
+ flash(text) {
759
+ show(text);
760
+ hideTimer = setTimeout(hide, 800);
761
+ }
762
+ };
763
+ }
764
+
765
+ // src/ui/components/statsOverlay.ts
766
+ function createStatsOverlay(container, video, media, i18n) {
767
+ let root = null;
768
+ let timer = null;
769
+ function collect() {
770
+ const q = video.getVideoPlaybackQuality?.();
771
+ const bufferedRanges = [];
772
+ for (let i = 0; i < video.buffered.length; i++) {
773
+ bufferedRanges.push(`${formatTime(video.buffered.start(i))}-${formatTime(video.buffered.end(i))}`);
774
+ }
775
+ const bw = media.bandwidthEstimate;
776
+ return [
777
+ ["Video Res", `${video.videoWidth}x${video.videoHeight}`],
778
+ ["Viewport", `${container.clientWidth}x${container.clientHeight}`],
779
+ ["Time", `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`],
780
+ ["Buffered", bufferedRanges.join(", ") || "-"],
781
+ ["Dropped Frames", q ? `${q.droppedVideoFrames} / ${q.totalVideoFrames}` : "N/A"],
782
+ ["Bandwidth", bw ? `${(bw / 1e3 / 1e3).toFixed(2)} Mbps` : "N/A"],
783
+ ["HLS Level", media.currentLevelInfo ?? "N/A"],
784
+ ["Speed", `${video.playbackRate}x`],
785
+ ["Volume", video.muted ? "muted" : `${Math.round(video.volume * 100)}%`],
786
+ ["Ready State", String(video.readyState)],
787
+ ["Source", video.currentSrc || "-"]
788
+ ];
789
+ }
790
+ function render() {
791
+ if (!root) return;
792
+ const dl = root.querySelector(".sp-stats-body");
793
+ dl.innerHTML = "";
794
+ for (const [label, value] of collect()) {
795
+ const row = createEl("div", { className: "sp-stats-row", parent: dl });
796
+ createEl("dt", { text: label, parent: row });
797
+ createEl("dd", { text: value, parent: row });
798
+ }
799
+ }
800
+ function show() {
801
+ if (root) return;
802
+ root = createEl("div", { className: "sp-stats", parent: container });
803
+ const closeBtn = createEl("button", {
804
+ className: "sp-stats-close",
805
+ text: "\u2715",
806
+ attrs: { type: "button", "aria-label": i18n.t("closeStats") },
807
+ parent: root
808
+ });
809
+ closeBtn.addEventListener("click", hide);
810
+ createEl("dl", { className: "sp-stats-body", parent: root });
811
+ render();
812
+ timer = setInterval(render, 500);
813
+ }
814
+ function hide() {
815
+ if (timer) {
816
+ clearInterval(timer);
817
+ timer = null;
818
+ }
819
+ root?.remove();
820
+ root = null;
821
+ }
822
+ return {
823
+ toggle() {
824
+ root ? hide() : show();
825
+ },
826
+ hide,
827
+ destroy: hide
828
+ };
829
+ }
830
+
831
+ // src/ui/components/stateOverlay.ts
832
+ function createStateOverlay(i18n) {
833
+ const el = createEl("div", { className: "sp-state" });
834
+ const spinner = createEl("div", { className: "sp-spinner", parent: el });
835
+ spinner.innerHTML = '<div class="sp-spinner-ring"></div>';
836
+ let errorEl = null;
837
+ let endedEl = null;
838
+ let countdownTimer = null;
839
+ function stopCountdown() {
840
+ if (countdownTimer) {
841
+ clearInterval(countdownTimer);
842
+ countdownTimer = null;
843
+ }
844
+ }
845
+ function hideError() {
846
+ errorEl?.remove();
847
+ errorEl = null;
848
+ }
849
+ function hideEnded() {
850
+ stopCountdown();
851
+ endedEl?.remove();
852
+ endedEl = null;
853
+ }
854
+ return {
855
+ el,
856
+ showLoading() {
857
+ el.classList.add("sp-loading");
858
+ },
859
+ hideLoading() {
860
+ el.classList.remove("sp-loading");
861
+ },
862
+ showError(onRetry) {
863
+ hideError();
864
+ hideEnded();
865
+ el.classList.remove("sp-loading");
866
+ errorEl = createEl("div", { className: "sp-state-panel", parent: el });
867
+ createEl("div", { className: "sp-state-message", text: i18n.t("loadError"), parent: errorEl });
868
+ const retryBtn = createEl("button", {
869
+ className: "sp-state-btn",
870
+ text: i18n.t("retry"),
871
+ attrs: { type: "button" },
872
+ parent: errorEl
873
+ });
874
+ retryBtn.addEventListener("click", () => {
875
+ hideError();
876
+ onRetry();
877
+ });
878
+ },
879
+ hideError,
880
+ showEnded({ onReplay, onNext, autoNextSeconds }) {
881
+ hideEnded();
882
+ hideError();
883
+ el.classList.remove("sp-loading");
884
+ endedEl = createEl("div", { className: "sp-state-panel", parent: el });
885
+ const buttons = createEl("div", { className: "sp-state-actions", parent: endedEl });
886
+ const replayBtn = createEl("button", {
887
+ className: "sp-state-btn",
888
+ text: i18n.t("replay"),
889
+ attrs: { type: "button" },
890
+ parent: buttons
891
+ });
892
+ replayBtn.addEventListener("click", () => {
893
+ hideEnded();
894
+ onReplay();
895
+ });
896
+ if (onNext) {
897
+ const nextBtn = createEl("button", {
898
+ className: "sp-state-btn sp-state-btn-primary",
899
+ text: i18n.t("playNext"),
900
+ attrs: { type: "button" },
901
+ parent: buttons
902
+ });
903
+ nextBtn.addEventListener("click", () => {
904
+ hideEnded();
905
+ onNext();
906
+ });
907
+ if (autoNextSeconds && autoNextSeconds > 0) {
908
+ let remain = autoNextSeconds;
909
+ const tip = createEl("div", {
910
+ className: "sp-state-countdown",
911
+ text: i18n.t("autoNextIn", { n: remain }),
912
+ parent: endedEl
913
+ });
914
+ const cancelBtn = createEl("button", {
915
+ className: "sp-state-cancel",
916
+ text: i18n.t("cancel"),
917
+ attrs: { type: "button" },
918
+ parent: tip
919
+ });
920
+ cancelBtn.addEventListener("click", () => {
921
+ stopCountdown();
922
+ tip.remove();
923
+ });
924
+ countdownTimer = setInterval(() => {
925
+ remain -= 1;
926
+ if (remain <= 0) {
927
+ hideEnded();
928
+ onNext();
929
+ } else {
930
+ tip.firstChild.textContent = i18n.t("autoNextIn", { n: remain });
931
+ }
932
+ }, 1e3);
933
+ }
934
+ }
935
+ },
936
+ hideEnded,
937
+ destroy() {
938
+ stopCountdown();
939
+ el.remove();
940
+ }
941
+ };
942
+ }
943
+
944
+ // src/i18n/index.ts
945
+ var zhCN = {
946
+ play: "\u64AD\u653E",
947
+ pause: "\u6682\u505C",
948
+ playPause: "\u64AD\u653E/\u6682\u505C (\u7A7A\u683C)",
949
+ prev: "\u4E0A\u4E00\u4E2A",
950
+ next: "\u4E0B\u4E00\u4E2A",
951
+ seekBack: "\u5FEB\u9000 {n} \u79D2 (\u2190)",
952
+ seekForward: "\u5FEB\u8FDB {n} \u79D2 (\u2192)",
953
+ speed: "\u64AD\u653E\u500D\u901F",
954
+ quality: "\u753B\u8D28",
955
+ qualityAuto: "\u81EA\u52A8",
956
+ aspectRatio: "\u753B\u9762\u6BD4\u4F8B",
957
+ ratioOriginal: "\u539F\u59CB",
958
+ audioTrack: "\u97F3\u8F68",
959
+ mute: "\u9759\u97F3 (M)",
960
+ fullscreen: "\u5168\u5C4F (F)",
961
+ pip: "\u753B\u4E2D\u753B (P)",
962
+ empty: "\u6682\u65E0\u53EF\u9009\u9879",
963
+ closeStats: "\u5173\u95ED\u7EDF\u8BA1\u4FE1\u606F",
964
+ seconds: "\u79D2",
965
+ volume: "\u97F3\u91CF",
966
+ muted: "\u9759\u97F3",
967
+ replay: "\u91CD\u65B0\u64AD\u653E",
968
+ playNext: "\u64AD\u653E\u4E0B\u4E00\u4E2A",
969
+ autoNextIn: "{n} \u79D2\u540E\u64AD\u653E\u4E0B\u4E00\u4E2A",
970
+ cancel: "\u53D6\u6D88",
971
+ loadError: "\u89C6\u9891\u52A0\u8F7D\u5931\u8D25",
972
+ retry: "\u91CD\u8BD5"
973
+ };
974
+ var en = {
975
+ play: "Play",
976
+ pause: "Pause",
977
+ playPause: "Play/Pause (Space)",
978
+ prev: "Previous",
979
+ next: "Next",
980
+ seekBack: "Rewind {n}s (\u2190)",
981
+ seekForward: "Forward {n}s (\u2192)",
982
+ speed: "Playback speed",
983
+ quality: "Quality",
984
+ qualityAuto: "Auto",
985
+ aspectRatio: "Aspect ratio",
986
+ ratioOriginal: "Original",
987
+ audioTrack: "Audio track",
988
+ mute: "Mute (M)",
989
+ fullscreen: "Fullscreen (F)",
990
+ pip: "Picture-in-Picture (P)",
991
+ empty: "Nothing available",
992
+ closeStats: "Close stats",
993
+ seconds: "s",
994
+ volume: "Volume",
995
+ muted: "Muted",
996
+ replay: "Replay",
997
+ playNext: "Play next",
998
+ autoNextIn: "Playing next in {n}s",
999
+ cancel: "Cancel",
1000
+ loadError: "Failed to load video",
1001
+ retry: "Retry"
1002
+ };
1003
+ var locales = { "zh-CN": zhCN, en };
1004
+ var I18n = class {
1005
+ constructor(locale = "zh-CN", overrides) {
1006
+ this.strings = { ...locales[locale] ?? zhCN, ...overrides };
1007
+ }
1008
+ t(key, params) {
1009
+ let text = this.strings[key];
1010
+ if (params) {
1011
+ for (const [k, v] of Object.entries(params)) text = text.replace(`{${k}}`, String(v));
1012
+ }
1013
+ return text;
1014
+ }
1015
+ };
1016
+ function registerLocale(name, strings) {
1017
+ locales[name] = strings;
1018
+ }
1019
+
1020
+ // src/utils/injectStyle.ts
1021
+ var STYLE_ID = "sweet-player-style";
1022
+ function injectStyle(css) {
1023
+ if (typeof document === "undefined") return;
1024
+ if (document.getElementById(STYLE_ID)) return;
1025
+ const style = document.createElement("style");
1026
+ style.id = STYLE_ID;
1027
+ style.textContent = css;
1028
+ document.head.appendChild(style);
1029
+ }
1030
+
1031
+ // src/utils/storage.ts
1032
+ var PREFS_KEY = "sweet-player:prefs";
1033
+ var PROGRESS_PREFIX = "sweet-player:progress:";
1034
+ function safeGet(key) {
1035
+ try {
1036
+ return localStorage.getItem(key);
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+ function safeSet(key, value) {
1042
+ try {
1043
+ localStorage.setItem(key, value);
1044
+ } catch {
1045
+ }
1046
+ }
1047
+ function loadPrefs() {
1048
+ const raw = safeGet(PREFS_KEY);
1049
+ if (!raw) return {};
1050
+ try {
1051
+ return JSON.parse(raw);
1052
+ } catch {
1053
+ return {};
1054
+ }
1055
+ }
1056
+ function savePrefs(prefs) {
1057
+ safeSet(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...prefs }));
1058
+ }
1059
+ function loadProgress(id) {
1060
+ const raw = safeGet(PROGRESS_PREFIX + id);
1061
+ const n = raw === null ? NaN : Number(raw);
1062
+ return Number.isFinite(n) ? n : null;
1063
+ }
1064
+ function saveProgress(id, time) {
1065
+ safeSet(PROGRESS_PREFIX + id, String(Math.floor(time)));
1066
+ }
1067
+ function clearProgress(id) {
1068
+ try {
1069
+ localStorage.removeItem(PROGRESS_PREFIX + id);
1070
+ } catch {
1071
+ }
1072
+ }
1073
+
1074
+ // src/styles/player.css
1075
+ var player_default = ".sweet-player {\n --sp-accent: #ff4d6d;\n --sp-bg: #000;\n --sp-text: #fff;\n --sp-bar-height: 4px;\n --sp-bar-hover-height: 6px;\n --sp-control-size: 36px;\n --sp-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n\n position: relative;\n width: 100%;\n height: 100%;\n background: var(--sp-bg);\n color: var(--sp-text);\n font-family: var(--sp-font);\n font-size: 13px;\n overflow: hidden;\n user-select: none;\n -webkit-user-select: none;\n outline: none;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.sweet-player *,\n.sweet-player *::before,\n.sweet-player *::after {\n box-sizing: border-box;\n}\n\n.sp-video {\n width: 100%;\n height: 100%;\n object-fit: contain;\n display: block;\n}\n\n/* \u5F3A\u5236\u753B\u9762\u6BD4\u4F8B\uFF1Avideo \u5C45\u4E2D\u3001\u6309\u6BD4\u4F8B\u9650\u5236\u5C3A\u5BF8\u5E76\u62C9\u4F38\u586B\u5145 */\n.sweet-player[data-ratio]:not([data-ratio='original']) .sp-video {\n width: auto;\n height: auto;\n max-width: 100%;\n max-height: 100%;\n aspect-ratio: var(--sp-forced-ratio);\n object-fit: fill;\n}\n\n/* ---------- \u9876\u90E8\u6807\u9898\u533A ---------- */\n.sp-top {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n padding: 12px 16px 28px;\n background: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent);\n transition: opacity 0.25s ease;\n pointer-events: none;\n}\n\n.sp-title {\n display: inline-block;\n max-width: 70%;\n font-size: 15px;\n font-weight: 500;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: auto;\n cursor: default;\n}\n\n/* ---------- \u7EDF\u8BA1\u4FE1\u606F\u8499\u5C42 ---------- */\n.sp-stats {\n position: absolute;\n top: 12px;\n left: 12px;\n z-index: 30;\n min-width: 300px;\n max-width: calc(100% - 24px);\n padding: 10px 14px;\n background: rgba(0, 0, 0, 0.75);\n border-radius: 6px;\n font-size: 12px;\n line-height: 1.7;\n font-family: 'SFMono-Regular', Consolas, monospace;\n pointer-events: auto;\n}\n\n.sp-stats-close {\n position: absolute;\n top: 6px;\n right: 6px;\n width: 22px;\n height: 22px;\n border: none;\n background: transparent;\n color: var(--sp-text);\n cursor: pointer;\n opacity: 0.7;\n font-size: 16px;\n line-height: 1;\n}\n\n.sp-stats-close:hover {\n opacity: 1;\n}\n\n.sp-stats-row {\n display: flex;\n gap: 12px;\n}\n\n.sp-stats-row dt {\n flex: 0 0 110px;\n opacity: 0.65;\n margin: 0;\n}\n\n.sp-stats-row dd {\n margin: 0;\n word-break: break-all;\n}\n\n/* ---------- \u4E2D\u592E OSD \u53CD\u9988 ---------- */\n.sp-osd {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n z-index: 20;\n padding: 10px 18px;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 6px;\n font-size: 16px;\n font-weight: 500;\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n white-space: nowrap;\n}\n\n.sp-osd.sp-visible {\n opacity: 1;\n}\n\n/* ---------- \u5E95\u90E8\u63A7\u5236\u533A ---------- */\n.sp-bottom {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n padding: 24px 12px 6px;\n background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);\n transition: opacity 0.25s ease;\n z-index: 10;\n}\n\n.sweet-player.sp-controls-hidden .sp-bottom,\n.sweet-player.sp-controls-hidden .sp-top {\n opacity: 0;\n pointer-events: none;\n}\n\n.sweet-player.sp-controls-hidden {\n cursor: none;\n}\n\n/* ---------- \u8FDB\u5EA6\u6761 ---------- */\n.sp-progress {\n position: relative;\n height: 14px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-bottom: 4px;\n}\n\n.sp-progress-track {\n position: relative;\n width: 100%;\n height: var(--sp-bar-height);\n background: rgba(255, 255, 255, 0.25);\n border-radius: 2px;\n overflow: visible;\n transition: height 0.1s ease;\n}\n\n.sp-progress:hover .sp-progress-track {\n height: var(--sp-bar-hover-height);\n}\n\n.sp-progress-buffered,\n.sp-progress-played {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n border-radius: 2px;\n}\n\n.sp-progress-buffered {\n background: rgba(255, 255, 255, 0.35);\n}\n\n.sp-progress-played {\n background: var(--sp-accent);\n}\n\n.sp-progress-thumb {\n position: absolute;\n top: 50%;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: var(--sp-accent);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 0.1s ease;\n pointer-events: none;\n}\n\n.sp-progress:hover .sp-progress-thumb,\n.sp-progress.sp-dragging .sp-progress-thumb {\n transform: translate(-50%, -50%) scale(1);\n}\n\n.sp-progress-tooltip {\n position: absolute;\n bottom: 18px;\n transform: translateX(-50%);\n padding: 2px 6px;\n background: rgba(0, 0, 0, 0.8);\n border-radius: 3px;\n font-size: 12px;\n display: none;\n pointer-events: none;\n white-space: nowrap;\n}\n\n.sp-progress:hover .sp-progress-tooltip {\n display: block;\n}\n\n/* ---------- \u63A7\u5236\u6309\u94AE\u884C ---------- */\n.sp-controls {\n display: flex;\n align-items: center;\n gap: 2px;\n}\n\n.sp-controls-spacer {\n flex: 1;\n}\n\n.sp-btn {\n width: var(--sp-control-size);\n height: var(--sp-control-size);\n border: none;\n background: transparent;\n color: var(--sp-text);\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 4px;\n padding: 0;\n opacity: 0.9;\n transition: opacity 0.1s ease, background 0.1s ease;\n}\n\n.sp-btn:hover {\n opacity: 1;\n background: rgba(255, 255, 255, 0.15);\n}\n\n.sp-btn svg {\n width: 22px;\n height: 22px;\n}\n\n.sp-btn:disabled {\n opacity: 0.35;\n cursor: not-allowed;\n}\n\n.sp-time {\n padding: 0 8px;\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n opacity: 0.95;\n}\n\n/* ---------- \u5F39\u51FA\u83DC\u5355 ---------- */\n.sp-menu {\n position: relative;\n}\n\n.sp-menu-btn {\n min-width: var(--sp-control-size);\n width: auto;\n padding: 0 8px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.sp-menu-panel {\n position: absolute;\n bottom: calc(100% + 8px);\n left: 50%;\n transform: translateX(-50%);\n min-width: 80px;\n max-height: 220px;\n overflow-y: auto;\n background: rgba(28, 28, 28, 0.95);\n border-radius: 6px;\n padding: 4px 0;\n display: none;\n z-index: 25;\n}\n\n.sp-menu.sp-open .sp-menu-panel {\n display: block;\n}\n\n.sp-menu-item {\n display: block;\n width: 100%;\n padding: 7px 16px;\n border: none;\n background: transparent;\n color: var(--sp-text);\n text-align: center;\n cursor: pointer;\n font-size: 13px;\n white-space: nowrap;\n}\n\n.sp-menu-item:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.sp-menu-item.sp-active {\n color: var(--sp-accent);\n font-weight: 600;\n}\n\n.sp-menu-empty {\n padding: 7px 16px;\n opacity: 0.5;\n white-space: nowrap;\n}\n\n/* ---------- \u72B6\u6001\u8499\u5C42\uFF1Aloading / \u9519\u8BEF / \u7ED3\u675F ---------- */\n.sp-state {\n position: absolute;\n inset: 0;\n z-index: 15;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n}\n\n.sp-spinner {\n opacity: 0;\n transition: opacity 0.2s ease 0.3s; /* \u5EF6\u8FDF\u51FA\u73B0\uFF0C\u907F\u514D\u77ED\u6682 seek \u95EA\u70C1 */\n}\n\n.sp-state.sp-loading .sp-spinner {\n opacity: 1;\n}\n\n.sp-spinner-ring {\n width: 44px;\n height: 44px;\n border: 4px solid rgba(255, 255, 255, 0.25);\n border-top-color: var(--sp-accent);\n border-radius: 50%;\n animation: sp-spin 0.8s linear infinite;\n}\n\n@keyframes sp-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n.sp-state-panel {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 14px;\n background: rgba(0, 0, 0, 0.55);\n pointer-events: auto;\n}\n\n.sp-state-message {\n font-size: 15px;\n opacity: 0.95;\n}\n\n.sp-state-actions {\n display: flex;\n gap: 12px;\n}\n\n.sp-state-btn {\n padding: 8px 22px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n border-radius: 18px;\n background: transparent;\n color: var(--sp-text);\n font-size: 14px;\n cursor: pointer;\n transition: background 0.1s ease;\n}\n\n.sp-state-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.sp-state-btn-primary {\n background: var(--sp-accent);\n border-color: var(--sp-accent);\n}\n\n.sp-state-btn-primary:hover {\n background: var(--sp-accent);\n filter: brightness(1.1);\n}\n\n.sp-state-countdown {\n font-size: 13px;\n opacity: 0.85;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.sp-state-cancel {\n border: none;\n background: transparent;\n color: var(--sp-accent);\n cursor: pointer;\n font-size: 13px;\n padding: 2px 6px;\n}\n\n/* ---------- \u97F3\u91CF ---------- */\n.sp-volume {\n display: flex;\n align-items: center;\n}\n\n.sp-volume-slider {\n width: 0;\n overflow: hidden;\n transition: width 0.15s ease;\n display: flex;\n align-items: center;\n}\n\n.sp-volume:hover .sp-volume-slider,\n.sp-volume.sp-dragging .sp-volume-slider {\n width: 64px;\n}\n\n.sp-volume-track {\n position: relative;\n width: 56px;\n height: var(--sp-bar-height);\n margin: 0 4px;\n background: rgba(255, 255, 255, 0.25);\n border-radius: 2px;\n cursor: pointer;\n}\n\n.sp-volume-fill {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n background: var(--sp-text);\n border-radius: 2px;\n}\n\n.sp-volume-thumb {\n position: absolute;\n top: 50%;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--sp-text);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n";
1076
+
1077
+ // src/player.ts
1078
+ var DEFAULT_RATES = [0.5, 1, 1.5, 2];
1079
+ var DEFAULT_RATIOS = ["original", "21:9", "16:9", "4:3"];
1080
+ var DEFAULT_LONG_SEEK_STEPS = [10, 30, 60];
1081
+ var DEFAULT_STEP_UP_INTERVAL = 2e3;
1082
+ var DEFAULT_AUTO_NEXT_SECONDS = 5;
1083
+ var CONTROLS_HIDE_DELAY = 3e3;
1084
+ var TITLE_CLICK_COUNT = 10;
1085
+ var TITLE_CLICK_WINDOW = 1500;
1086
+ var SINGLE_CLICK_DELAY = 250;
1087
+ var PROGRESS_SAVE_INTERVAL = 5e3;
1088
+ var PROGRESS_END_GUARD = 10;
1089
+ var SweetPlayer = class {
1090
+ constructor(options) {
1091
+ this.emitter = new EventEmitter();
1092
+ this.hideTimer = null;
1093
+ this.clickTimer = null;
1094
+ this.progressTimer = null;
1095
+ this.titleClicks = 0;
1096
+ this.lastTitleClick = 0;
1097
+ this.currentRatio = "original";
1098
+ /** 画质/音轨菜单当前是否由 hls.js 自动接管(业务 setQualities 会关闭) */
1099
+ this.hlsManagedQuality = false;
1100
+ this.hlsManagedAudio = false;
1101
+ this.disposers = [];
1102
+ this.pluginCleanups = [];
1103
+ this.destroyed = false;
1104
+ this.options = options;
1105
+ this.i18n = new I18n(options.locale, options.localeStrings);
1106
+ const target = typeof options.container === "string" ? document.querySelector(options.container) : options.container;
1107
+ if (!target) throw new Error(`[sweet-player] container not found: ${options.container}`);
1108
+ injectStyle(player_default);
1109
+ this.container = createEl("div", {
1110
+ className: "sweet-player",
1111
+ attrs: { tabindex: "0", "data-ratio": "original" },
1112
+ parent: target
1113
+ });
1114
+ this.video = createEl("video", {
1115
+ className: "sp-video",
1116
+ attrs: { playsinline: "" },
1117
+ parent: this.container
1118
+ });
1119
+ const persist = options.persist !== false;
1120
+ const prefs = persist ? loadPrefs() : {};
1121
+ this.video.volume = clamp(prefs.volume ?? options.volume ?? 100, 0, 100) / 100;
1122
+ this.video.muted = prefs.muted ?? options.muted ?? false;
1123
+ if (options.autoplay) this.video.autoplay = true;
1124
+ const autoQuality = options.autoQuality !== false;
1125
+ this.media = new MediaController(this.video, this.emitter, options.hlsConfig, {
1126
+ onLevels: autoQuality && !options.qualities?.length ? (levels) => this.applyHlsLevels(levels) : void 0,
1127
+ onAudioTracks: autoQuality && !options.audioTracks?.length ? (tracks) => this.applyHlsAudioTracks(tracks) : void 0
1128
+ });
1129
+ this.osd = createOsd();
1130
+ this.stats = createStatsOverlay(this.container, this.video, this.media, this.i18n);
1131
+ this.state = createStateOverlay(this.i18n);
1132
+ this.controls = createControls({
1133
+ video: this.video,
1134
+ title: options.title ?? "",
1135
+ i18n: this.i18n,
1136
+ playbackRates: options.playbackRates ?? DEFAULT_RATES,
1137
+ aspectRatios: options.aspectRatios ?? DEFAULT_RATIOS,
1138
+ seekStep: options.seekStep ?? 10,
1139
+ actions: {
1140
+ togglePlay: () => this.toggle(),
1141
+ seekBy: (d) => this.seekBy(d),
1142
+ setRate: (r) => this.setRate(r),
1143
+ setVolume: (v) => this.setVolume(v),
1144
+ toggleMute: () => this.setMuted(!this.video.muted),
1145
+ setAspectRatio: (r) => this.setAspectRatio(r),
1146
+ toggleFullscreen: () => this.toggleFullscreen(),
1147
+ togglePip: () => this.togglePip(),
1148
+ selectQuality: (q) => this.handleQualitySelect(q),
1149
+ selectAudioTrack: (t) => this.handleAudioTrackSelect(t),
1150
+ onPrev: options.onPrev,
1151
+ onNext: options.onNext,
1152
+ onTitleClick: () => this.handleTitleClick()
1153
+ }
1154
+ });
1155
+ this.container.appendChild(this.controls.topEl);
1156
+ this.container.appendChild(this.controls.bottomEl);
1157
+ this.container.appendChild(this.state.el);
1158
+ this.container.appendChild(this.osd.el);
1159
+ if (options.qualities?.length) this.setQualities(options.qualities);
1160
+ if (options.audioTracks?.length) this.setAudioTracks(options.audioTracks);
1161
+ const seekStep = options.seekStep ?? 10;
1162
+ this.keyboard = new KeyboardController(
1163
+ this.container,
1164
+ {
1165
+ togglePlay: () => this.toggle(),
1166
+ seekBy: (d) => this.seekBy(d),
1167
+ onLongSeekProgress: (acc) => this.osd.show(`${acc > 0 ? "+" : ""}${acc} ${this.i18n.t("seconds")}`),
1168
+ onLongSeekCommit: (acc) => {
1169
+ this.osd.hide();
1170
+ if (acc !== 0) this.seekBy(acc, false);
1171
+ },
1172
+ adjustVolume: (d) => this.setVolume(Math.round(this.video.volume * 100) + d),
1173
+ toggleFullscreen: () => this.toggleFullscreen(),
1174
+ toggleMute: () => this.setMuted(!this.video.muted)
1175
+ },
1176
+ {
1177
+ seekStep,
1178
+ longSeekSteps: options.longSeek?.steps ?? DEFAULT_LONG_SEEK_STEPS,
1179
+ stepUpInterval: options.longSeek?.stepUpInterval ?? DEFAULT_STEP_UP_INTERVAL
1180
+ }
1181
+ );
1182
+ this.gestures = new GestureController(
1183
+ this.container,
1184
+ this.video,
1185
+ {
1186
+ seekBy: (d) => this.seekBy(d),
1187
+ onSeekPreview: (d) => this.osd.show(
1188
+ `${d > 0 ? "+" : ""}${d} ${this.i18n.t("seconds")} (${formatTime(
1189
+ clamp(this.video.currentTime + d, 0, this.video.duration || 0)
1190
+ )})`
1191
+ ),
1192
+ onSeekCommit: (d) => {
1193
+ this.osd.hide();
1194
+ if (d !== 0) this.seekBy(d, false);
1195
+ },
1196
+ adjustVolume: (d) => this.setVolume(Math.round(this.video.volume * 100) + d),
1197
+ toggleControls: () => {
1198
+ this.container.classList.contains("sp-controls-hidden") ? this.scheduleHide() : this.hideControlsNow();
1199
+ },
1200
+ toggleFullscreen: () => this.toggleFullscreen()
1201
+ },
1202
+ seekStep
1203
+ );
1204
+ this.bindMediaEvents();
1205
+ this.bindActivityTracking();
1206
+ this.disposers.push(
1207
+ onFullscreenChange(this.container, (fs) => {
1208
+ this.controls.updateFullscreen(fs);
1209
+ this.emitter.emit("fullscreenchange", fs);
1210
+ })
1211
+ );
1212
+ if (persist && prefs.rate) {
1213
+ this.video.defaultPlaybackRate = prefs.rate;
1214
+ this.video.playbackRate = prefs.rate;
1215
+ }
1216
+ this.media.load(options.src);
1217
+ this.controls.volume.update(Math.round(this.video.volume * 100), this.video.muted);
1218
+ this.controls.updateRate(this.video.playbackRate);
1219
+ if (persist) this.bindPersistence();
1220
+ if (options.id) this.bindProgressMemory(options.id);
1221
+ options.plugins?.forEach((p) => this.use(p));
1222
+ }
1223
+ // ---------- 公开 API ----------
1224
+ play() {
1225
+ return this.video.play();
1226
+ }
1227
+ pause() {
1228
+ this.video.pause();
1229
+ }
1230
+ toggle() {
1231
+ this.video.paused ? void this.play().catch(() => {
1232
+ }) : this.pause();
1233
+ }
1234
+ seek(time) {
1235
+ this.video.currentTime = clamp(time, 0, this.video.duration || 0);
1236
+ }
1237
+ /** 相对跳转;showOsd 为 false 时不显示中央提示 */
1238
+ seekBy(delta, showOsd = true) {
1239
+ this.seek(this.video.currentTime + delta);
1240
+ if (showOsd) this.osd.flash(`${delta > 0 ? "+" : ""}${delta} ${this.i18n.t("seconds")}`);
1241
+ }
1242
+ setRate(rate) {
1243
+ this.video.playbackRate = rate;
1244
+ }
1245
+ /** 0-100 */
1246
+ setVolume(volume) {
1247
+ const v = clamp(volume, 0, 100);
1248
+ this.video.volume = v / 100;
1249
+ if (v > 0) this.video.muted = false;
1250
+ this.osd.flash(`${this.i18n.t("volume")} ${v}%`);
1251
+ }
1252
+ setMuted(muted) {
1253
+ this.video.muted = muted;
1254
+ this.osd.flash(muted ? this.i18n.t("muted") : `${this.i18n.t("volume")} ${Math.round(this.video.volume * 100)}%`);
1255
+ }
1256
+ setAspectRatio(ratio) {
1257
+ this.currentRatio = ratio;
1258
+ this.container.setAttribute("data-ratio", ratio);
1259
+ if (ratio !== "original") {
1260
+ this.container.style.setProperty("--sp-forced-ratio", ratio.replace(":", " / "));
1261
+ }
1262
+ this.controls.updateRatio(ratio);
1263
+ this.emitter.emit("aspectratiochange", ratio);
1264
+ }
1265
+ get aspectRatio() {
1266
+ return this.currentRatio;
1267
+ }
1268
+ /** 运行时更新画质列表 */
1269
+ setQualities(qualities, active) {
1270
+ this.hlsManagedQuality = false;
1271
+ this.controls.qualityMenu.setItems(qualities.map((q) => ({ label: q.label, value: q })));
1272
+ if (active) this.controls.qualityMenu.setActive(active);
1273
+ }
1274
+ /** 运行时更新音轨列表 */
1275
+ setAudioTracks(tracks, active) {
1276
+ this.hlsManagedAudio = false;
1277
+ this.controls.audioMenu.setItems(tracks.map((t) => ({ label: t.label, value: t })));
1278
+ if (active) this.controls.audioMenu.setActive(active);
1279
+ }
1280
+ async toggleFullscreen() {
1281
+ await toggleFullscreen(this.container).catch(() => {
1282
+ });
1283
+ }
1284
+ get fullscreen() {
1285
+ return isFullscreen(this.container);
1286
+ }
1287
+ async togglePip() {
1288
+ try {
1289
+ if (document.pictureInPictureElement === this.video) {
1290
+ await document.exitPictureInPicture();
1291
+ } else {
1292
+ await this.video.requestPictureInPicture();
1293
+ }
1294
+ } catch {
1295
+ }
1296
+ }
1297
+ load(src) {
1298
+ this.state.hideEnded();
1299
+ this.state.hideError();
1300
+ this.media.load(src);
1301
+ }
1302
+ setTitle(title) {
1303
+ const el = this.controls.topEl.querySelector(".sp-title");
1304
+ if (el) el.textContent = title;
1305
+ }
1306
+ /** 安装插件;返回本次插件的卸载函数 */
1307
+ use(plugin) {
1308
+ const cleanup = plugin.apply(this);
1309
+ const dispose = typeof cleanup === "function" ? cleanup : () => {
1310
+ };
1311
+ this.pluginCleanups.push(dispose);
1312
+ return dispose;
1313
+ }
1314
+ on(event, fn) {
1315
+ return this.emitter.on(event, fn);
1316
+ }
1317
+ off(event, fn) {
1318
+ this.emitter.off(event, fn);
1319
+ }
1320
+ destroy() {
1321
+ if (this.destroyed) return;
1322
+ this.destroyed = true;
1323
+ this.saveProgressNow();
1324
+ this.emitter.emit("destroy", void 0);
1325
+ this.pluginCleanups.forEach((d) => d());
1326
+ this.keyboard.destroy();
1327
+ this.gestures.destroy();
1328
+ this.stats.destroy();
1329
+ this.state.destroy();
1330
+ this.controls.destroy();
1331
+ this.disposers.forEach((d) => d());
1332
+ if (this.hideTimer) clearTimeout(this.hideTimer);
1333
+ if (this.clickTimer) clearTimeout(this.clickTimer);
1334
+ if (this.progressTimer) clearInterval(this.progressTimer);
1335
+ this.media.destroy();
1336
+ this.emitter.removeAll();
1337
+ this.container.remove();
1338
+ }
1339
+ // ---------- 内部:hls 自动画质/音轨 ----------
1340
+ applyHlsLevels(levels) {
1341
+ const auto = { label: this.i18n.t("qualityAuto"), value: -1 };
1342
+ const items = [auto, ...levels.map((l) => ({ label: l.label, value: l.index }))];
1343
+ this.setQualities(items, auto);
1344
+ this.hlsManagedQuality = true;
1345
+ }
1346
+ applyHlsAudioTracks(tracks) {
1347
+ const items = tracks.map((t) => ({ label: t.label, value: t.index }));
1348
+ this.setAudioTracks(items, items[0]);
1349
+ this.hlsManagedAudio = true;
1350
+ }
1351
+ handleQualitySelect(quality) {
1352
+ this.controls.qualityMenu.setActive(quality);
1353
+ if (this.hlsManagedQuality && typeof quality.value === "number") {
1354
+ this.media.setLevel(quality.value);
1355
+ } else if (quality.src) {
1356
+ const time = this.video.currentTime;
1357
+ this.media.load(quality.src);
1358
+ this.video.currentTime = time;
1359
+ }
1360
+ this.options.onQualityChange?.(quality);
1361
+ this.emitter.emit("qualitychange", quality);
1362
+ }
1363
+ handleAudioTrackSelect(track) {
1364
+ this.controls.audioMenu.setActive(track);
1365
+ if (this.hlsManagedAudio && typeof track.value === "number") {
1366
+ this.media.setAudioTrack(track.value);
1367
+ }
1368
+ this.options.onAudioTrackChange?.(track);
1369
+ this.emitter.emit("audiotrackchange", track);
1370
+ }
1371
+ // ---------- 内部:持久化 ----------
1372
+ bindPersistence() {
1373
+ const onVolume = () => savePrefs({ volume: Math.round(this.video.volume * 100), muted: this.video.muted });
1374
+ const onRate = () => savePrefs({ rate: this.video.playbackRate });
1375
+ this.video.addEventListener("volumechange", onVolume);
1376
+ this.video.addEventListener("ratechange", onRate);
1377
+ this.disposers.push(() => {
1378
+ this.video.removeEventListener("volumechange", onVolume);
1379
+ this.video.removeEventListener("ratechange", onRate);
1380
+ });
1381
+ }
1382
+ bindProgressMemory(id) {
1383
+ const restore = () => {
1384
+ const saved = loadProgress(id);
1385
+ if (saved !== null && saved > 3 && saved < this.video.duration - PROGRESS_END_GUARD) {
1386
+ this.video.currentTime = saved;
1387
+ }
1388
+ };
1389
+ this.video.addEventListener("loadedmetadata", restore, { once: true });
1390
+ this.progressTimer = setInterval(() => {
1391
+ if (!this.video.paused) this.saveProgressNow();
1392
+ }, PROGRESS_SAVE_INTERVAL);
1393
+ }
1394
+ saveProgressNow() {
1395
+ const id = this.options.id;
1396
+ if (!id || !this.video.duration) return;
1397
+ if (this.video.currentTime >= this.video.duration - PROGRESS_END_GUARD) {
1398
+ clearProgress(id);
1399
+ } else if (this.video.currentTime > 3) {
1400
+ saveProgress(id, this.video.currentTime);
1401
+ }
1402
+ }
1403
+ // ---------- 内部:交互 ----------
1404
+ handleTitleClick() {
1405
+ const now = Date.now();
1406
+ this.titleClicks = now - this.lastTitleClick <= TITLE_CLICK_WINDOW ? this.titleClicks + 1 : 1;
1407
+ this.lastTitleClick = now;
1408
+ if (this.titleClicks >= TITLE_CLICK_COUNT) {
1409
+ this.titleClicks = 0;
1410
+ this.stats.toggle();
1411
+ }
1412
+ }
1413
+ bindMediaEvents() {
1414
+ const v = this.video;
1415
+ const listen = (event, fn) => {
1416
+ v.addEventListener(event, fn);
1417
+ this.disposers.push(() => v.removeEventListener(event, fn));
1418
+ };
1419
+ listen("loadedmetadata", () => {
1420
+ this.controls.updateTime();
1421
+ this.emitter.emit("ready", void 0);
1422
+ });
1423
+ listen("play", () => {
1424
+ this.state.hideEnded();
1425
+ this.controls.updatePlayState(true);
1426
+ this.scheduleHide();
1427
+ this.emitter.emit("play", void 0);
1428
+ });
1429
+ listen("pause", () => {
1430
+ this.controls.updatePlayState(false);
1431
+ this.showControls();
1432
+ this.emitter.emit("pause", void 0);
1433
+ });
1434
+ listen("ended", () => {
1435
+ this.saveProgressNow();
1436
+ this.showControls();
1437
+ const onNext = this.options.onNext;
1438
+ const autoNext = this.options.autoNext;
1439
+ this.state.showEnded({
1440
+ onReplay: () => {
1441
+ this.seek(0);
1442
+ void this.play().catch(() => {
1443
+ });
1444
+ },
1445
+ onNext,
1446
+ autoNextSeconds: onNext && autoNext ? typeof autoNext === "number" ? autoNext : DEFAULT_AUTO_NEXT_SECONDS : void 0
1447
+ });
1448
+ this.emitter.emit("ended", void 0);
1449
+ });
1450
+ listen("timeupdate", () => {
1451
+ this.controls.updateTime();
1452
+ this.emitter.emit("timeupdate", { currentTime: v.currentTime, duration: v.duration });
1453
+ });
1454
+ listen("progress", () => this.controls.progress.update());
1455
+ listen("ratechange", () => {
1456
+ this.controls.updateRate(v.playbackRate);
1457
+ this.emitter.emit("ratechange", v.playbackRate);
1458
+ });
1459
+ listen("volumechange", () => {
1460
+ const volume = Math.round(v.volume * 100);
1461
+ this.controls.volume.update(volume, v.muted);
1462
+ this.emitter.emit("volumechange", { volume, muted: v.muted });
1463
+ });
1464
+ listen("waiting", () => this.state.showLoading());
1465
+ listen("stalled", () => this.state.showLoading());
1466
+ listen("seeking", () => this.state.showLoading());
1467
+ listen("canplay", () => this.state.hideLoading());
1468
+ listen("playing", () => {
1469
+ this.state.hideLoading();
1470
+ this.state.hideError();
1471
+ });
1472
+ listen("seeked", () => this.state.hideLoading());
1473
+ listen("error", () => this.showErrorState({ type: "media", detail: v.error }));
1474
+ this.disposers.push(
1475
+ this.emitter.on("error", (payload) => {
1476
+ if (payload.type.startsWith("hls-")) this.showErrorStateUi();
1477
+ })
1478
+ );
1479
+ listen("enterpictureinpicture", () => this.emitter.emit("pipchange", true));
1480
+ listen("leavepictureinpicture", () => this.emitter.emit("pipchange", false));
1481
+ const onVideoClick = (e) => {
1482
+ if (e.target !== this.video || e.pointerType === "touch") return;
1483
+ if (this.clickTimer) return;
1484
+ this.clickTimer = setTimeout(() => {
1485
+ this.clickTimer = null;
1486
+ this.toggle();
1487
+ }, SINGLE_CLICK_DELAY);
1488
+ };
1489
+ const onVideoDblClick = (e) => {
1490
+ if (e.target !== this.video) return;
1491
+ if (this.clickTimer) {
1492
+ clearTimeout(this.clickTimer);
1493
+ this.clickTimer = null;
1494
+ }
1495
+ void this.toggleFullscreen();
1496
+ };
1497
+ this.container.addEventListener("click", onVideoClick);
1498
+ this.container.addEventListener("dblclick", onVideoDblClick);
1499
+ this.disposers.push(() => {
1500
+ this.container.removeEventListener("click", onVideoClick);
1501
+ this.container.removeEventListener("dblclick", onVideoDblClick);
1502
+ });
1503
+ }
1504
+ showErrorState(payload) {
1505
+ this.emitter.emit("error", payload);
1506
+ this.showErrorStateUi();
1507
+ }
1508
+ showErrorStateUi() {
1509
+ this.state.hideLoading();
1510
+ this.state.showError(() => {
1511
+ const time = this.video.currentTime;
1512
+ this.media.reload();
1513
+ if (time > 0) this.video.currentTime = time;
1514
+ void this.play().catch(() => {
1515
+ });
1516
+ });
1517
+ }
1518
+ // ---------- 控制栏自动隐藏 ----------
1519
+ showControls() {
1520
+ this.container.classList.remove("sp-controls-hidden");
1521
+ if (this.hideTimer) {
1522
+ clearTimeout(this.hideTimer);
1523
+ this.hideTimer = null;
1524
+ }
1525
+ }
1526
+ hideControlsNow() {
1527
+ if (!this.video.paused) this.container.classList.add("sp-controls-hidden");
1528
+ }
1529
+ scheduleHide() {
1530
+ this.showControls();
1531
+ this.hideTimer = setTimeout(() => this.hideControlsNow(), CONTROLS_HIDE_DELAY);
1532
+ }
1533
+ bindActivityTracking() {
1534
+ const onActivity = (e) => {
1535
+ if (e.pointerType === "touch") return;
1536
+ if (this.video.paused) this.showControls();
1537
+ else this.scheduleHide();
1538
+ };
1539
+ this.container.addEventListener("pointermove", onActivity);
1540
+ this.container.addEventListener("pointerdown", onActivity);
1541
+ this.disposers.push(() => {
1542
+ this.container.removeEventListener("pointermove", onActivity);
1543
+ this.container.removeEventListener("pointerdown", onActivity);
1544
+ });
1545
+ }
1546
+ };
1547
+ // Annotate the CommonJS export names for ESM import in node:
1548
+ 0 && (module.exports = {
1549
+ SweetPlayer,
1550
+ registerLocale
1551
+ });